Add log collection, script output capture, and timestamp fixes

Log Collector: a new collect_log action reads a plaintext log file from a
known path and returns its tail for viewing and download. Rides the existing
endpoints.updateapp grant rather than inventing one, since collecting a log is
the same trust level as pushing an application. Collected logs land in their
own table with per-asset retention of 20 instead of overloading the command
audit row, and the agent refuses UNC, traversal, wildcards, directories and
non-text content independently of the server check.

collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the
action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops
anything unnamed - so every policied endpoint refused log collection outright,
including for admins, who saw a permission-shaped error for a grant they held.
Exemption from policy is not exemption from permission: the elevated grant
still applies, and a regression test asserts both halves so a future edit
cannot quietly loosen the grant while "fixing" the policy gate.

Script output capture: successful runs can return output, gated per script by
captureoutput. Failures always returned it; success never did, which left a
green badge as the only evidence a run produced.

Timestamps: Activity and Operator Reports rendered date-only because the shared
formatter's 'date' branch drops the clock time. Run Scripts additionally
labels its timestamp, since a bare time meant "completed" on a terminal row but
"queued" on an in-flight one - indistinguishable, and the wrong reading.

Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked
for it fails closed on the unknown-action default.
This commit is contained in:
benjamin.baker
2026-09-09 19:23:43 -04:00
parent 494b837bae
commit b49fb60c0c
19 changed files with 2207 additions and 21 deletions

View File

@@ -594,6 +594,229 @@
</div>
</template>
</div>
<!-- Recent runs. A script that exits zero shows a green badge and
nothing else, which is not enough to tell "it did the work" from
"PowerShell started and stopped cleanly". The exit code, the
context it ran as and the directory it really ran in are shown so
the operator can judge that themselves. -->
<div class="card">
<h3>Recent Script Runs</h3>
<div v-if="scriptrunsloading" class="runscripts-empty">
Loading recent runs...
</div>
<div v-else-if="scriptruns.length === 0" class="runscripts-empty">
No scripts have been run on this computer yet.
</div>
<ul v-else class="scriptrun-list">
<li
v-for="run in scriptruns"
:key="run.endpointcommandid"
class="scriptrun-row"
>
<div class="scriptrun-head">
<span
class="scriptrun-badge"
:class="`scriptrun-badge--${run.status}`"
>{{ scriptRunStatusLabel(run) }}</span>
<span class="scriptrun-name">
{{ run.scriptname || run.target }}
</span>
<span class="scriptrun-when" :title="scriptRunTimeTitle(run)">
{{ scriptRunTimeLabel(run) }}
</span>
</div>
<p v-if="run.resultmessage" class="scriptrun-message">
{{ run.resultmessage }}
</p>
<dl v-if="run.resultdetail" class="scriptrun-facts">
<template v-if="run.resultdetail.exitcode !== undefined
&& run.resultdetail.exitcode !== null">
<dt>Exit code</dt>
<dd class="mono">{{ run.resultdetail.exitcode }}</dd>
</template>
<template v-if="run.resultdetail.runcontext">
<dt>Ran as</dt>
<dd>
{{ run.resultdetail.runcontext === 'user'
? 'The logged-on user' : 'SYSTEM' }}
</dd>
</template>
<template v-if="run.resultdetail.workingdirectory">
<dt>Ran in</dt>
<dd class="mono">{{ run.resultdetail.workingdirectory }}</dd>
</template>
</dl>
<!-- Output is only reported back on failure; on success it stays
on the endpoint. Shown verbatim when present because it is
the thing that explains the failure. -->
<pre
v-if="run.resultdetail && run.resultdetail.output"
class="scriptrun-output"
>{{ run.resultdetail.output }}</pre>
<p
v-if="run.status === 'succeeded' && !hasRunEvidence(run)"
class="scriptrun-note"
>
The agent reported success but sent no detail. This agent may
predate run reporting.
</p>
</li>
</ul>
<p class="scriptrun-foot">
A zero exit code means the script ran and PowerShell exited cleanly.
It does not confirm the script achieved what it was written to do.
Output is shown for failures, and for successes only when the script
is set to return it.
</p>
</div>
</div>
<!-- Log Collector. Manual retrieval of a plaintext log from a known path.
Gated on the application install/update grant, because reading an
app's log is part of the same job as pushing that app. -->
<div v-show="activegroup === 'logcollector'" class="detail-grid">
<div class="card">
<h3>Collect a Log File</h3>
<p class="logcollect-intro">
Reads a plaintext log from this computer and stores a copy in ShopDB
so it can be read here or downloaded. The agent only ever reads the
file - it is never modified, moved or deleted.
</p>
<div v-if="!liveness" class="logcollect-note logcollect-note--warn">
This computer is not currently checking in. A collection queued now
would sit unclaimed, so collecting is disabled until it reports
again. Previously collected logs are still readable below.
</div>
<div class="logcollect-form">
<label class="logcollect-label" for="logcollect-path">
Full path to the log file
</label>
<input
id="logcollect-path"
v-model="logpath"
class="logcollect-input mono"
type="text"
placeholder="C:\ProgramData\ShopDB\EndpointAgent\agent.log"
:disabled="collectingLog || !liveness"
@keyup.enter="collectLog"
/>
<p v-if="logpatherror" class="logcollect-error">{{ logpatherror }}</p>
<p class="logcollect-hint">
Local path including the drive letter. Plaintext files only
(.log, .txt, .xml, .json, .csv and similar). Large files are
trimmed to the last 2 MB, which keeps the most recent entries.
</p>
</div>
<div class="logcollect-actions">
<button
class="btn btn-warning"
:disabled="!logpath.trim() || collectingLog || !liveness"
@click="collectLog"
>{{ collectingLog ? 'Collecting...' : 'Collect Log' }}</button>
</div>
</div>
<!-- The most recently collected log, opened for reading. Separate from
the history so the thing just collected is visible without a
second click. -->
<div v-if="activelog" class="card">
<div class="logview-head">
<h3>{{ activelog.sourcepath }}</h3>
<button class="btn btn-secondary btn-sm" @click="downloadLog(activelog)">
Download
</button>
</div>
<dl class="logview-facts">
<dt>Collected</dt>
<dd>{{ formatDateTime(activelog.collectedat) }}</dd>
<template v-if="activelog.collectedbyusername">
<dt>Collected by</dt>
<dd>{{ activelog.collectedbyusername }}</dd>
</template>
<template v-if="activelog.filemodifiedat">
<dt>File last written</dt>
<dd>{{ formatDateTime(activelog.filemodifiedat) }}</dd>
</template>
<template v-if="activelog.linecount !== null">
<dt>Lines</dt>
<dd class="mono">{{ activelog.linecount }}</dd>
</template>
<dt>Size on disk</dt>
<dd class="mono">{{ formatBytes(activelog.filesizebytes) }}</dd>
</dl>
<p v-if="activelog.istruncated" class="logcollect-note">
This file was larger than the collection limit, so only the end of it
was kept. The oldest entries are not shown.
</p>
<div v-if="logcontentloading" class="runscripts-empty">
Loading log contents...
</div>
<pre v-else class="logview-pane">{{ activelog.content }}</pre>
</div>
<!-- History. Capped at 20 server-side; the note says so, because a
silently trimmed list looks like data loss. -->
<div class="card">
<h3>Collected Logs</h3>
<div v-if="logcollectionsloading" class="runscripts-empty">
Loading collected logs...
</div>
<div v-else-if="logcollections.length === 0" class="runscripts-empty">
No logs have been collected from this computer yet.
</div>
<ul v-else class="logcollect-list">
<li
v-for="entry in logcollections"
:key="entry.endpointlogcollectionid"
class="logcollect-row"
:class="{ 'logcollect-row--active':
activelog && activelog.endpointlogcollectionid === entry.endpointlogcollectionid }"
>
<div class="logcollect-rowhead">
<span class="logcollect-path mono">{{ entry.sourcepath }}</span>
<span class="logcollect-when">
{{ formatDateTime(entry.collectedat) }}
</span>
</div>
<div class="logcollect-rowmeta">
<span>{{ formatBytes(entry.filesizebytes) }}</span>
<span v-if="entry.linecount !== null">
{{ entry.linecount }} lines
</span>
<span v-if="entry.istruncated" class="logcollect-trunc">
trimmed
</span>
<span v-if="entry.collectedbyusername">
by {{ entry.collectedbyusername }}
</span>
</div>
<div class="logcollect-rowactions">
<button class="btn btn-secondary btn-sm" @click="viewLog(entry)">
View
</button>
<button class="btn btn-secondary btn-sm" @click="downloadLog(entry)">
Download
</button>
</div>
</li>
</ul>
<p class="scriptrun-foot">
The 20 most recent collections are kept for this computer. Older ones
are removed automatically as new logs are collected.
</p>
</div>
</div>
<!-- Audit Footer -->
@@ -930,6 +1153,14 @@ const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript
const canViewOperatorReports = computed(
() => authStore.hasPermission('endpoints.operatorreports'))
// Gates the Log Collector tab. Rides the application install/update grant
// rather than endpoints.runscript, matching ELEVATED_ACTION_PERMISSIONS on the
// server: collecting an app's log is part of the same job as pushing that app,
// and reading a text file is strictly weaker than the installer execution this
// grant already permits. Declared here with the others because detailtabs reads
// it during setup.
const canUpdateApp = computed(() => authStore.hasPermission('endpoints.updateapp'))
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
const has = (key) => filled.has ? filled.has(key) : false
@@ -971,6 +1202,16 @@ const detailtabs = computed(() => {
if (canRunScript.value && liveness.value) {
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
}
// Log Collector rides the application install/update grant, not the script
// grant: the reason to read a log is almost always to find out why an app
// push behaved as it did, so it is the same job as pushing the app. Reading a
// file is strictly weaker than the installer execution that grant already
// allows. Liveness-gated like the others - collection queues against a live
// agent. The history stays readable, so the tab is not hidden once the
// machine goes quiet; only the Collect button disables.
if (canUpdateApp.value) {
tabs.push({ key: 'logcollector', label: 'Log Collector' })
}
return tabs
})
@@ -1518,9 +1759,232 @@ async function loadScriptLibrary() {
// Fetch on first view of the tab rather than on page load: most visits to a PC
// never open this, and the library is irrelevant to every other tab.
watch(activegroup, (group) => {
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
if (group === 'runscripts' && canRunScript.value) {
loadScriptLibrary()
loadScriptRuns()
}
if (group === 'logcollector' && canUpdateApp.value) {
loadLogCollections()
}
})
// Recent run history for this endpoint. Refetched rather than accumulated
// client-side so the panel reflects what the server actually recorded,
// including runs pushed by another operator from another session.
const scriptruns = ref([])
const scriptrunsloading = ref(false)
async function loadScriptRuns() {
scriptrunsloading.value = true
try {
const response = await endpointsApi.listScriptRuns(computer.value.assetid, 5)
scriptruns.value = response.data.data || []
} catch (error) {
// History is supporting evidence, not the action itself. A failure here
// must not read as a failed script run, so it is not toasted.
scriptruns.value = []
} finally {
scriptrunsloading.value = false
}
}
// ---- Log Collector ----
const logpath = ref('')
const logpatherror = ref('')
const collectingLog = ref(false)
const logcollections = ref([])
const logcollectionsloading = ref(false)
const activelog = ref(null)
const logcontentloading = ref(false)
// History for this endpoint. Metadata only - bodies are fetched one at a time,
// because 20 rows of multi-hundred-KB logs would make opening the tab far more
// expensive than reading the one log that is actually wanted.
async function loadLogCollections() {
logcollectionsloading.value = true
try {
const response = await endpointsApi.listLogCollections(computer.value.assetid)
logcollections.value = response.data.data || []
} catch (error) {
// Supporting evidence, not the action itself; a failure here must not read
// as a failed collection.
logcollections.value = []
} finally {
logcollectionsloading.value = false
}
}
// Client-side shape check purely for immediate feedback. The server and the
// agent both re-validate; this exists so an obvious typo does not cost a round
// trip and a poll cycle to report back.
function validateLogPath(raw) {
const path = (raw || '').trim()
if (!path) return 'Enter the full path to the log file'
const normalized = path.replace(/\//g, '\\')
if (normalized.startsWith('\\\\')) {
return 'Network (UNC) paths are not supported; use a local path'
}
if (!/^[A-Za-z]:\\/.test(normalized)) {
return 'Include the drive letter, for example C:\\ProgramData\\App\\app.log'
}
if (normalized.split('\\').some((part) => part === '..')) {
return 'Path must not contain ".." segments'
}
if (normalized.includes('*') || normalized.includes('?')) {
return 'Wildcards are not supported; name a single log file'
}
return ''
}
async function collectLog() {
const path = logpath.value.trim()
logpatherror.value = validateLogPath(path)
if (logpatherror.value) return
collectingLog.value = true
try {
const response = await endpointsApi.enqueueCommand(
computer.value.assetid, 'collect_log', path)
const commandId = response.data.data?.endpointcommandid
// Wait for the agent rather than optimistically showing a result: the file
// is read on the endpoint, so until the command completes there is nothing
// to display and no way to know whether the path even exists.
const outcome = commandId ? await waitForCommand(commandId) : 'timeout'
await loadLogCollections()
if (outcome === 'succeeded') {
toast.success('Log collected')
// Open the newest collection, which is the one just produced.
const newest = logcollections.value[0]
if (newest) await viewLog(newest)
} else if (outcome === 'failed') {
// The agent's refusal reason is the useful part (file missing, access
// denied, not a text file), so it is fetched and surfaced verbatim
// instead of a generic failure message.
toast.error(await collectFailureReason(commandId))
} else {
toast.info(
'Collection queued. It will appear here once the agent reports back.')
}
} catch (error) {
toast.error(apiError(error, 'Failed to collect the log'))
} finally {
collectingLog.value = false
}
}
// Why a collection failed. waitForCommand only reports the terminal state, but
// the agent's message is the actionable part - "No file found at ...",
// "Access denied", "does not appear to be a plaintext file" - so it is read off
// the command row rather than replaced with a generic failure.
async function collectFailureReason(commandId) {
const fallback = 'The log could not be collected'
try {
const response = await endpointsApi.listCommands(computer.value.assetid)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
return command?.resultmessage || fallback
} catch {
return fallback
}
}
async function viewLog(entry) {
logcontentloading.value = true
// Show the row's metadata immediately so the pane is not blank while the
// body loads.
activelog.value = { ...entry, content: '' }
try {
const response = await endpointsApi.getLogCollection(
entry.endpointlogcollectionid)
activelog.value = response.data.data
} catch (error) {
activelog.value = null
toast.error(apiError(error, 'Failed to open the collected log'))
} finally {
logcontentloading.value = false
}
}
async function downloadLog(entry) {
try {
const response = await endpointsApi.downloadLogCollection(
entry.endpointlogcollectionid)
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.download = entry.filename || 'collected.log'
document.body.appendChild(link)
link.click()
// Revoke and detach, or every download leaks a blob URL and a node for the
// lifetime of the page.
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
toast.error(apiError(error, 'Failed to download the collected log'))
}
}
// Byte counts are shown to a human deciding whether a log is worth opening, so
// they are scaled rather than printed raw.
function formatBytes(value) {
if (value === null || value === undefined) return 'Unknown'
const bytes = Number(value)
if (!Number.isFinite(bytes)) return 'Unknown'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// Queued and claimed are live states, not outcomes. Labelling them "pending"
// would imply the run finished and reported nothing.
function scriptRunStatusLabel(run) {
if (run.status === 'failed') return 'Failed'
if (run.status === 'claimed') return 'Running'
return 'Queued'
}
// The timestamp on a run row means two different things depending on the state:
// when it FINISHED for a terminal run, when it was QUEUED for one still in
// flight. Rendered bare, those are indistinguishable, and a queued row showing
// its queue time reads as a completed run that finished then - which is exactly
// the wrong conclusion while the script is still executing. The label says which
// one is being shown.
function scriptRunTimeLabel(run) {
const finished = run.status === 'succeeded' || run.status === 'failed'
if (finished && run.completedat) return formatDateTime(run.completedat)
return `Queued ${formatDateTime(run.createdat)}`
}
// Hover detail. A terminal row only shows its finish time, so the queue time -
// the gap that tells an operator the agent was slow to pick the job up - would
// otherwise be lost.
function scriptRunTimeTitle(run) {
const parts = [`Queued ${formatDateTime(run.createdat)}`]
if (run.completedat) parts.push(`Completed ${formatDateTime(run.completedat)}`)
return parts.join('\n')
}
// Whether the agent sent anything beyond the status itself. Drives the note
// that distinguishes "nothing to report" from "this agent cannot report".
function hasRunEvidence(run) {
const detail = run.resultdetail
if (!detail) return false
return detail.exitcode !== undefined && detail.exitcode !== null
}
function formatDateTime(value) {
if (!value) return 'unknown'
// Server sends naive UTC isoformat; mark it as UTC so it renders in local
// time rather than being read as local and shifting by the offset.
const raw = value.endsWith('Z') ? value : `${value}Z`
const when = new Date(raw)
return Number.isNaN(when.getTime()) ? 'unknown' : when.toLocaleString()
}
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
// confirmRestart - these start real work on a live machine and some of them
// change system state - and the server re-checks endpoints.corptools regardless.
@@ -1578,12 +2042,22 @@ async function confirmRunScript() {
firingScript.value = true
try {
await endpointsApi.enqueueCommand(
const response = await endpointsApi.enqueueCommand(
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
// "Queued", not "finished": the agent collects this on its next check-in,
// and the script's own result comes back separately. Saying "ran" here
// would claim an outcome nothing has observed yet.
toast.success(`${script.scriptname} queued for ${label}`)
// Show the queued row straight away, then again once it reaches a terminal
// state, so the history reflects the run the operator just triggered
// without them having to leave and re-enter the tab.
await loadScriptRuns()
const commandId = response?.data?.data?.endpointcommandid
if (commandId) {
await waitForCommand(commandId)
await loadScriptRuns()
}
} catch (error) {
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
} finally {
@@ -2092,6 +2566,258 @@ async function confirmRetire() {
.runscripts-actions {
margin-top: 1rem;
}
/* Recent script runs. Themed off the same variables as the rest of the page so
the panel follows dark mode rather than pinning its own colours. */
.scriptrun-list {
list-style: none;
margin: 0;
padding: 0;
}
.scriptrun-row {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.scriptrun-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.scriptrun-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.scriptrun-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-light);
border: 1px solid var(--border);
}
.scriptrun-badge--succeeded {
color: var(--success-dark);
border-color: var(--success);
}
.scriptrun-badge--failed {
color: var(--danger);
border-color: var(--danger);
}
[data-theme="dark"] .scriptrun-badge--succeeded { color: var(--success); }
.scriptrun-name {
font-weight: 600;
color: var(--text);
font-size: 0.875rem;
}
.scriptrun-when {
margin-left: auto;
color: var(--text-light);
font-size: 0.75rem;
}
.scriptrun-message {
margin: 0.375rem 0 0;
color: var(--text);
font-size: 0.8125rem;
}
.scriptrun-facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
margin: 0.5rem 0 0;
font-size: 0.8125rem;
}
.scriptrun-facts dt {
color: var(--text-light);
}
.scriptrun-facts dd {
margin: 0;
color: var(--text);
}
.scriptrun-facts dd.mono {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
word-break: break-all;
}
/* Agent output is arbitrary text: it scrolls rather than stretching the card,
and wraps so a long single line does not push the layout sideways. */
.scriptrun-output {
margin: 0.5rem 0 0;
padding: 0.5rem;
max-height: 12rem;
overflow: auto;
background: var(--bg-subtle, var(--bg));
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
.scriptrun-note,
.scriptrun-foot {
margin: 0.5rem 0 0;
color: var(--text-light);
font-size: 0.75rem;
}
.scriptrun-foot {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
/* Log Collector. Themed off the same variables as the panels above so it
follows dark mode rather than pinning its own colours. */
.logcollect-intro {
margin: 0 0 0.75rem;
color: var(--text-light);
font-size: 0.8125rem;
}
.logcollect-form {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.logcollect-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
}
.logcollect-input {
width: 100%;
padding: 0.5rem;
background: var(--bg-subtle, var(--bg));
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 0.8125rem;
}
.logcollect-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.logcollect-hint {
margin: 0.25rem 0 0;
color: var(--text-light);
font-size: 0.75rem;
}
.logcollect-error {
margin: 0.25rem 0 0;
color: var(--danger);
font-size: 0.75rem;
}
.logcollect-note {
margin: 0.5rem 0 0;
color: var(--text-light);
font-size: 0.75rem;
}
.logcollect-note--warn {
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border-left: 3px solid var(--warning);
background: var(--bg-subtle, var(--bg));
border-radius: 4px;
}
.logcollect-actions {
margin-top: 1rem;
}
.logview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
/* The collected path can be long; it wraps rather than forcing the card wide. */
.logview-head h3 {
margin: 0;
font-size: 0.9375rem;
word-break: break-all;
}
.logview-facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0.75rem 0 0;
font-size: 0.8125rem;
}
.logview-facts dt {
color: var(--text-light);
}
.logview-facts dd {
margin: 0;
color: var(--text);
}
/* The log body. Scrolls rather than stretching the card, and wraps so a long
line does not push the layout sideways. */
.logview-pane {
margin: 0.75rem 0 0;
padding: 0.75rem;
max-height: 28rem;
overflow: auto;
background: var(--bg-subtle, var(--bg));
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
.logcollect-list {
list-style: none;
margin: 0;
padding: 0;
}
.logcollect-row {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
}
.logcollect-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.logcollect-row--active {
border-left: 3px solid var(--primary, var(--warning));
padding-left: 0.5rem;
}
.logcollect-rowhead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.logcollect-path {
font-size: 0.8125rem;
color: var(--text);
word-break: break-all;
}
.logcollect-when {
color: var(--text-light);
font-size: 0.75rem;
white-space: nowrap;
}
.logcollect-rowmeta {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.25rem;
color: var(--text-light);
font-size: 0.75rem;
}
.logcollect-trunc {
color: var(--warning-dark, var(--warning));
}
[data-theme="dark"] .logcollect-trunc {
color: var(--warning);
}
.logcollect-rowactions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.triage-chip.critical .triage-dot { background: var(--danger); }
.triage-chip.ok { color: var(--success-dark); }
.triage-chip.warning { color: var(--warning-dark); }