Initial
This commit is contained in:
@@ -82,7 +82,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-jobStatus="{ row }"><span :class="['status-pill', row.jobStatus]">{{ row.jobStatus }}</span></template>
|
<template #cell-outputStatus="{ row }">
|
||||||
|
<span :class="['status-pill', row.outputStatus]">{{ row.outputStatus }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-outcome="{ row }">
|
||||||
|
<div class="job-output-outcome">
|
||||||
|
<strong>{{ row.hostSuccessCount }}/{{ row.hostTotalCount || 0 }}</strong>
|
||||||
|
<small>{{ row.hostFailedCount }} failed</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #cell-latestAt="{ row }">{{ shortDate(row.latestAt) }}</template>
|
<template #cell-latestAt="{ row }">{{ shortDate(row.latestAt) }}</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="job-output-row-actions">
|
<div class="job-output-row-actions">
|
||||||
@@ -118,7 +126,8 @@ const filters = reactive({ search: '', kind: '', page: 1, pageSize: 8, sort: 'la
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Job' },
|
{ key: 'name', label: 'Job' },
|
||||||
{ key: 'kind', label: 'Artifact' },
|
{ key: 'kind', label: 'Artifact' },
|
||||||
{ key: 'jobStatus', label: 'Status' },
|
{ key: 'outputStatus', label: 'Status' },
|
||||||
|
{ key: 'outcome', label: 'Success', sortable: false },
|
||||||
{ key: 'latestAt', label: 'Generated' },
|
{ key: 'latestAt', label: 'Generated' },
|
||||||
{ key: 'actions', label: 'Actions', sortable: false }
|
{ key: 'actions', label: 'Actions', sortable: false }
|
||||||
];
|
];
|
||||||
@@ -140,6 +149,12 @@ const groupedOutputs = computed(() => {
|
|||||||
scriptName: output.scriptName || '',
|
scriptName: output.scriptName || '',
|
||||||
runplanName: output.runplanName || '',
|
runplanName: output.runplanName || '',
|
||||||
jobStatus: output.jobStatus || '',
|
jobStatus: output.jobStatus || '',
|
||||||
|
outputStatus: output.outputStatus || output.jobStatus || '',
|
||||||
|
hostTotalCount: output.hostTotalCount || 0,
|
||||||
|
hostSuccessCount: output.hostSuccessCount || 0,
|
||||||
|
hostFailedCount: output.hostFailedCount || 0,
|
||||||
|
hostSkippedCount: output.hostSkippedCount || 0,
|
||||||
|
hostCanceledCount: output.hostCanceledCount || 0,
|
||||||
latestAt: output.updatedAt || output.createdAt || '',
|
latestAt: output.updatedAt || output.createdAt || '',
|
||||||
createdAt: output.createdAt || '',
|
createdAt: output.createdAt || '',
|
||||||
artifacts: []
|
artifacts: []
|
||||||
@@ -149,6 +164,12 @@ const groupedOutputs = computed(() => {
|
|||||||
group.artifacts.push(output);
|
group.artifacts.push(output);
|
||||||
if (String(output.updatedAt || output.createdAt || '') > String(group.latestAt || '')) group.latestAt = output.updatedAt || output.createdAt;
|
if (String(output.updatedAt || output.createdAt || '') > String(group.latestAt || '')) group.latestAt = output.updatedAt || output.createdAt;
|
||||||
if (!group.jobStatus && output.jobStatus) group.jobStatus = output.jobStatus;
|
if (!group.jobStatus && output.jobStatus) group.jobStatus = output.jobStatus;
|
||||||
|
if (output.outputStatus) group.outputStatus = output.outputStatus;
|
||||||
|
group.hostTotalCount = Math.max(group.hostTotalCount || 0, output.hostTotalCount || 0);
|
||||||
|
group.hostSuccessCount = Math.max(group.hostSuccessCount || 0, output.hostSuccessCount || 0);
|
||||||
|
group.hostFailedCount = Math.max(group.hostFailedCount || 0, output.hostFailedCount || 0);
|
||||||
|
group.hostSkippedCount = Math.max(group.hostSkippedCount || 0, output.hostSkippedCount || 0);
|
||||||
|
group.hostCanceledCount = Math.max(group.hostCanceledCount || 0, output.hostCanceledCount || 0);
|
||||||
}
|
}
|
||||||
return [...groups.values()].map((group) => ({
|
return [...groups.values()].map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
@@ -164,6 +185,8 @@ const filteredGroups = computed(() => {
|
|||||||
group.title,
|
group.title,
|
||||||
group.subtitle,
|
group.subtitle,
|
||||||
group.jobStatus,
|
group.jobStatus,
|
||||||
|
group.outputStatus,
|
||||||
|
`${group.hostSuccessCount}/${group.hostTotalCount}`,
|
||||||
group.runplanName,
|
group.runplanName,
|
||||||
group.scriptName,
|
group.scriptName,
|
||||||
group.jobId,
|
group.jobId,
|
||||||
@@ -205,6 +228,7 @@ function sortableValue(row, key) {
|
|||||||
if (key === 'kind') return kindLabel(selectedArtifact(row)?.kind || '');
|
if (key === 'kind') return kindLabel(selectedArtifact(row)?.kind || '');
|
||||||
if (key === 'name') return row.title || '';
|
if (key === 'name') return row.title || '';
|
||||||
if (key === 'updatedAt') return row.latestAt || '';
|
if (key === 'updatedAt') return row.latestAt || '';
|
||||||
|
if (key === 'outputStatus') return row.outputStatus || row.jobStatus || '';
|
||||||
return String(row[key] ?? '').toLowerCase();
|
return String(row[key] ?? '').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,27 +405,32 @@ function formatBytes(bytes = 0) {
|
|||||||
|
|
||||||
.job-output-list :deep(th:nth-child(1)),
|
.job-output-list :deep(th:nth-child(1)),
|
||||||
.job-output-list :deep(td:nth-child(1)) {
|
.job-output-list :deep(td:nth-child(1)) {
|
||||||
width: 34%;
|
width: 30%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-list :deep(th:nth-child(2)),
|
.job-output-list :deep(th:nth-child(2)),
|
||||||
.job-output-list :deep(td:nth-child(2)) {
|
.job-output-list :deep(td:nth-child(2)) {
|
||||||
width: 24%;
|
width: 22%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-list :deep(th:nth-child(3)),
|
.job-output-list :deep(th:nth-child(3)),
|
||||||
.job-output-list :deep(td:nth-child(3)) {
|
.job-output-list :deep(td:nth-child(3)) {
|
||||||
width: 12%;
|
width: 11%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-list :deep(th:nth-child(4)),
|
.job-output-list :deep(th:nth-child(4)),
|
||||||
.job-output-list :deep(td:nth-child(4)) {
|
.job-output-list :deep(td:nth-child(4)) {
|
||||||
width: 16%;
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-output-list :deep(th:nth-child(5)),
|
||||||
|
.job-output-list :deep(td:nth-child(5)) {
|
||||||
|
width: 14%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-list :deep(th:last-child),
|
.job-output-list :deep(th:last-child),
|
||||||
.job-output-list :deep(td:last-child) {
|
.job-output-list :deep(td:last-child) {
|
||||||
width: 14%;
|
width: 13%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-title {
|
.job-output-title {
|
||||||
@@ -458,6 +487,26 @@ function formatBytes(bytes = 0) {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-output-outcome {
|
||||||
|
display: inline-grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-output-outcome strong {
|
||||||
|
color: #d8ffe8;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-output-outcome small {
|
||||||
|
color: rgba(247, 154, 167, .82);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 13px;
|
left: 13px;
|
||||||
|
|||||||
@@ -17,11 +17,26 @@ function visibleJobClause() {
|
|||||||
function outputSelect() {
|
function outputSelect() {
|
||||||
return `
|
return `
|
||||||
SELECT jo.*, j.status AS job_status, j.runplan_id, j.script_id, j.triggered_by,
|
SELECT jo.*, j.status AS job_status, j.runplan_id, j.script_id, j.triggered_by,
|
||||||
r.name AS runplan_name, s.name AS script_name
|
r.name AS runplan_name, s.name AS script_name,
|
||||||
|
COALESCE(hs.host_total_count, 0) AS host_total_count,
|
||||||
|
COALESCE(hs.host_success_count, 0) AS host_success_count,
|
||||||
|
COALESCE(hs.host_failed_count, 0) AS host_failed_count,
|
||||||
|
COALESCE(hs.host_skipped_count, 0) AS host_skipped_count,
|
||||||
|
COALESCE(hs.host_canceled_count, 0) AS host_canceled_count
|
||||||
FROM job_outputs jo
|
FROM job_outputs jo
|
||||||
JOIN jobs j ON j.id = jo.job_id
|
JOIN jobs j ON j.id = jo.job_id
|
||||||
LEFT JOIN runplans r ON r.id = j.runplan_id
|
LEFT JOIN runplans r ON r.id = j.runplan_id
|
||||||
LEFT JOIN scripts s ON s.id = j.script_id
|
LEFT JOIN scripts s ON s.id = j.script_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT job_id,
|
||||||
|
COUNT(*) AS host_total_count,
|
||||||
|
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS host_success_count,
|
||||||
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS host_failed_count,
|
||||||
|
SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) AS host_skipped_count,
|
||||||
|
SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) AS host_canceled_count
|
||||||
|
FROM job_hosts
|
||||||
|
GROUP BY job_id
|
||||||
|
) hs ON hs.job_id = j.id
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -453,6 +453,19 @@ export function camelJobLog(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function camelJobOutput(row) {
|
export function camelJobOutput(row) {
|
||||||
|
const hostSuccessCount = row.host_success_count || 0;
|
||||||
|
const hostFailedCount = row.host_failed_count || 0;
|
||||||
|
const hostSkippedCount = row.host_skipped_count || 0;
|
||||||
|
const hostCanceledCount = row.host_canceled_count || 0;
|
||||||
|
const outputStatus = hostSuccessCount > 0
|
||||||
|
? 'completed'
|
||||||
|
: hostFailedCount > 0
|
||||||
|
? 'failed'
|
||||||
|
: hostCanceledCount > 0
|
||||||
|
? 'canceled'
|
||||||
|
: hostSkippedCount > 0
|
||||||
|
? 'skipped'
|
||||||
|
: row.job_status || row.status || '';
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
jobId: row.job_id,
|
jobId: row.job_id,
|
||||||
@@ -467,6 +480,12 @@ export function camelJobOutput(row) {
|
|||||||
scriptId: row.script_id || null,
|
scriptId: row.script_id || null,
|
||||||
scriptName: row.script_name || null,
|
scriptName: row.script_name || null,
|
||||||
jobStatus: row.job_status || row.status || '',
|
jobStatus: row.job_status || row.status || '',
|
||||||
|
outputStatus,
|
||||||
|
hostTotalCount: row.host_total_count || 0,
|
||||||
|
hostSuccessCount,
|
||||||
|
hostFailedCount,
|
||||||
|
hostSkippedCount,
|
||||||
|
hostCanceledCount,
|
||||||
triggeredBy: row.triggered_by || null,
|
triggeredBy: row.triggered_by || null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
|||||||
@@ -97,6 +97,32 @@ test('script test runs generate the same FileLocker job output artifacts', async
|
|||||||
updateSettings({ allow_script_execution: 'true' });
|
updateSettings({ allow_script_execution: 'true' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('job output status is successful when at least one host completed', async () => {
|
||||||
|
const scriptId = 'scr_job_output_mixed_status_test';
|
||||||
|
const jobId = 'job_output_mixed_status_test';
|
||||||
|
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
|
||||||
|
VALUES (?, 'Mixed Host Status.ps1', 'Write-Output mixed', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
|
||||||
|
const hostA = createHost({ name: 'Mixed Success Host', address: 'mixed-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
|
||||||
|
const hostB = createHost({ name: 'Mixed Failed Host', address: 'mixed-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
|
||||||
|
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at)
|
||||||
|
VALUES (?, NULL, ?, 'failed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts);
|
||||||
|
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
|
||||||
|
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_mixed_success', jobId, hostA.id, ts, ts);
|
||||||
|
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
|
||||||
|
VALUES (?, ?, ?, 'failed', 1, ?, ?)`).run('jhost_mixed_failed', jobId, hostB.id, ts, ts);
|
||||||
|
addLog(jobId, hostA.id, 'stdout', 'success payload', 1);
|
||||||
|
addLog(jobId, hostB.id, 'stderr', 'failed payload', 2);
|
||||||
|
|
||||||
|
await generateJobOutputs(jobId);
|
||||||
|
const output = listJobOutputsForJob(jobId, owner, false).find((row) => row.kind === 'stdout-pdf');
|
||||||
|
|
||||||
|
assert.equal(output.jobStatus, 'failed');
|
||||||
|
assert.equal(output.outputStatus, 'completed');
|
||||||
|
assert.equal(output.hostSuccessCount, 1);
|
||||||
|
assert.equal(output.hostFailedCount, 1);
|
||||||
|
assert.equal(output.hostTotalCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('job output datatable uses one last-stdout row per host for unstructured output', async () => {
|
test('job output datatable uses one last-stdout row per host for unstructured output', async () => {
|
||||||
const scriptId = 'scr_job_output_last_line_test';
|
const scriptId = 'scr_job_output_last_line_test';
|
||||||
const jobId = 'job_output_last_line_test';
|
const jobId = 'job_output_last_line_test';
|
||||||
|
|||||||
Reference in New Issue
Block a user