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:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user