mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
Keep incomplete backup entries out of completed restore totals
Use the same completion predicate for latest backups, restore-point and byte totals, PBS coverage and per-day count/volume charts. Keep running and failed entries visible, with separate counts and honest inventory-entry labels. Completion is not verification, restore or guest thaw. Add registered model and consuming-table regressions for mixed and incomplete-only inventories, unknown chronology and completion transitions. Guest operations and server policy are unchanged. Change-source: pulse-maintainer Contract-Neutral: Apply established completion semantics and inventory-entry wording only; resource transport, identity, persistence and shared table contracts are unchanged.
This commit is contained in:
@@ -20,6 +20,18 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Backups distinguishes completed points from inventory entries (24 October 2026)
|
||||
|
||||
The existing health strip names completed restore points and separately counts
|
||||
running and failed entries. The existing per-day chart measures only completed
|
||||
restore points in both count and volume modes, while the dated table and its
|
||||
filters keep incomplete entries available for inspection. Table and day-group
|
||||
counters name entries so they cannot imply completion. The incomplete-only
|
||||
chart state says that no completed dated points are visible, not that no
|
||||
backup activity occurred. The consuming Backups tests cover summary, chart,
|
||||
volume, date selection and route views together; existing table interaction
|
||||
primitives, access boundaries and routes are unchanged.
|
||||
|
||||
### Backup posture failure and retry stay source-local (24 October 2026)
|
||||
|
||||
The Backups posture notice offers a keyboard-operable retry with the existing
|
||||
|
||||
@@ -21,6 +21,20 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Completed restore totals exclude incomplete inventory (24 October 2026)
|
||||
|
||||
The Backups restore-point total, completed-byte total, PBS-target count and
|
||||
per-day count/volume chart all exclude explicitly running or terminally
|
||||
incomplete artifacts. The same completion predicate selects the last backup.
|
||||
Running and failed entries remain in the dated inventory and row evidence,
|
||||
with separate summary counts; inventory and day-group counts name entries,
|
||||
not completed backups. Unknown dates and unverified completed points remain
|
||||
evidence, not absent or failed points. Completion establishes neither an
|
||||
independent backup (guest snapshots remain distinct), tested restore nor guest
|
||||
thaw. Registered recovery-model and consuming-table tests cover mixed,
|
||||
incomplete-only, unknown-date and completion-transition controls without any
|
||||
new backup execution, QGA requests, schema or persisted state.
|
||||
|
||||
### Failed posture refreshes withdraw retained assessments (24 October 2026)
|
||||
|
||||
Protection posture remains visible while a refresh is pending, but a settled
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {
|
||||
buildProxmoxBackupRecoveryModel,
|
||||
coverageRowMatchesSearch,
|
||||
isCompletedRecoveryArtifact,
|
||||
selectWorkloadRecoveryArtifacts,
|
||||
recoverableArtifactMatchesSearch,
|
||||
type RecoverableArtifact,
|
||||
@@ -502,11 +503,13 @@ export const ProxmoxBackupsTable: Component<{
|
||||
recoveryModel().recoverableArtifacts,
|
||||
(artifact) => artifact.createdMs,
|
||||
(artifact) =>
|
||||
artifact.sourceKind === 'pbs'
|
||||
? 'pbs'
|
||||
: artifact.sourceKind === 'archive'
|
||||
? 'archive'
|
||||
: 'snapshot',
|
||||
!isCompletedRecoveryArtifact(artifact)
|
||||
? null
|
||||
: artifact.sourceKind === 'pbs'
|
||||
? 'pbs'
|
||||
: artifact.sourceKind === 'archive'
|
||||
? 'archive'
|
||||
: 'snapshot',
|
||||
{
|
||||
now: new Date(nowMs()),
|
||||
getValue:
|
||||
@@ -753,8 +756,17 @@ export const ProxmoxBackupsTable: Component<{
|
||||
tail={
|
||||
<span>
|
||||
{liveTotalCount()} targets ·{' '}
|
||||
{recoveryModel().coverageSummary.recoverableArtifacts} restore points
|
||||
{recoveryModel().coverageSummary.recoverableArtifacts} completed restore{' '}
|
||||
{recoveryModel().coverageSummary.recoverableArtifacts === 1 ? 'point' : 'points'}
|
||||
{inventoryIncomplete() ? ' read' : ''}
|
||||
<Show when={recoveryModel().coverageSummary.runningArtifacts > 0}>
|
||||
{' · '}
|
||||
{recoveryModel().coverageSummary.runningArtifacts} running
|
||||
</Show>
|
||||
<Show when={recoveryModel().coverageSummary.failedArtifacts > 0}>
|
||||
{' · '}
|
||||
{recoveryModel().coverageSummary.failedArtifacts} failed
|
||||
</Show>
|
||||
<Show when={recoveryModel().coverageSummary.withPBS > 0}>
|
||||
{' · '}
|
||||
{recoveryModel().coverageSummary.withPBS} with{' '}
|
||||
@@ -830,7 +842,10 @@ export const ProxmoxBackupsTable: Component<{
|
||||
when={hasRecoverableActivityInRange()}
|
||||
fallback={
|
||||
<div class="flex flex-col gap-2 rounded-md border border-border-subtle px-3 py-3 text-xs text-muted sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>No dated backup activity in the selected {chartRange()}-day window.</span>
|
||||
<span>
|
||||
No completed restore points with known dates in the selected {chartRange()}
|
||||
-day window.
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -854,10 +869,10 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<BackupActivityChart
|
||||
title={
|
||||
recoverableMetricMode() === 'volume'
|
||||
? 'Backup volume per day'
|
||||
: 'Backups per day'
|
||||
? 'Completed restore volume per day'
|
||||
: 'Completed restore points per day'
|
||||
}
|
||||
noun="backup"
|
||||
noun="restore point"
|
||||
segmentKinds={RECOVERABLE_SEGMENT_KINDS}
|
||||
range={chartRange}
|
||||
onRangeChange={setChartRange}
|
||||
@@ -906,8 +921,8 @@ export const ProxmoxBackupsTable: Component<{
|
||||
rowNoun={
|
||||
view() === 'date'
|
||||
? inventoryIncomplete()
|
||||
? 'backups read'
|
||||
: 'backups'
|
||||
? 'backup entries read'
|
||||
: 'backup entries'
|
||||
: 'targets'
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -475,7 +475,7 @@ export function ProxmoxRecoverableTable(props: {
|
||||
{item.kind === 'day' ? item.label : ''}{' '}
|
||||
<span class="ml-2 normal-case tracking-normal text-muted">
|
||||
{item.kind === 'day' ? item.count : 0}{' '}
|
||||
{item.kind === 'day' && item.count === 1 ? 'backup' : 'backups'}
|
||||
{item.kind === 'day' && item.count === 1 ? 'entry' : 'entries'}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -438,7 +438,7 @@ describe('ProxmoxBackupsTable', () => {
|
||||
expect(screen.getByText('main / minipc')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('cell', {
|
||||
name: `${getRecoveryFullDateLabel('2026-05-25')} 3 backups`,
|
||||
name: `${getRecoveryFullDateLabel('2026-05-25')} 3 entries`,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
const tables = screen.getAllByRole('table');
|
||||
@@ -994,7 +994,7 @@ describe('independent Proxmox backup inventory reads', () => {
|
||||
mountBackupReadState();
|
||||
await screen.findByText(/PVE backup inventory is loading/);
|
||||
await waitFor(() => expect(recoverableRows()).toHaveLength(1));
|
||||
expect(screen.getByText(/1 restore points read/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 completed restore point read/)).toBeInTheDocument();
|
||||
pending.resolve(jsonResponse(readPvePayload));
|
||||
await waitFor(() => expect(recoverableRows()).toHaveLength(2));
|
||||
expect(screen.queryByText(/inventory is loading/)).not.toBeInTheDocument();
|
||||
@@ -1303,7 +1303,7 @@ describe('backup-date-evidence connected inventory', () => {
|
||||
);
|
||||
expect(warning).toHaveAttribute('role', 'status');
|
||||
expect(warning).toHaveTextContent('excluded from the activity chart and day filters');
|
||||
expect(screen.getByText(/No dated backup activity/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/No completed restore points with known dates/)).toBeInTheDocument();
|
||||
const future = [...document.querySelectorAll('[data-proxmox-backup-row="recoverable"]')].find(
|
||||
(row) => row.textContent?.includes('PBS'),
|
||||
)! as HTMLElement;
|
||||
@@ -1609,3 +1609,91 @@ describe('Coverage withdraws failed protection assessments', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('completed restore-point summary and activity', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const mountInventory = (incompleteOnly = false) => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(BACKUP_IDENTITY_NOW);
|
||||
const completedPBS = identityPBS({ id: 'complete', size: 2048 });
|
||||
const runningPBS = identityPBS({ id: 'running', inProgress: true, size: 1024 * 1024 });
|
||||
const failedPBS = identityPBS({
|
||||
id: 'failed',
|
||||
inProgress: true,
|
||||
writeActivityObserved: true,
|
||||
writeActive: false,
|
||||
size: 2 * 1024 * 1024,
|
||||
});
|
||||
apiFetchMock.mockImplementation((url: string) =>
|
||||
Promise.resolve(
|
||||
jsonResponse({
|
||||
data: url.endsWith('/pbs')
|
||||
? { backups: [...(incompleteOnly ? [] : [completedPBS]), runningPBS, failedPBS] }
|
||||
: {
|
||||
storageBackups: incompleteOnly ? [] : [identityArchive({ size: 1024 })],
|
||||
guestSnapshots: incompleteOnly ? [] : [identitySnapshot()],
|
||||
backupTasks: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
apiFetchJSONMock.mockResolvedValue({ data: [], policy: {} });
|
||||
window.history.replaceState({}, '', '/proxmox/backups/date');
|
||||
return renderInRouter(() => (
|
||||
<ProxmoxBackupsTable
|
||||
emptyIcon={<span />}
|
||||
workloads={[identityGuest('east-100', 'east', 'pve-1')]}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
it('counts completed points and volume separately while retaining running and failed rows for inspection', async () => {
|
||||
const { container } = mountInventory();
|
||||
await screen.findByText(/3 completed restore points · 1 running · 1 failed/);
|
||||
expect(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')).toHaveLength(5);
|
||||
expect(
|
||||
screen.getByRole('cell', { name: `${getRecoveryFullDateLabel('2026-10-04')} 5 entries` }),
|
||||
).toBeInTheDocument();
|
||||
const day = screen.getByRole('button', { name: /: 3 restore points$/ });
|
||||
expect(day).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /: 5 restore points$/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Completed restore points per day')).toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Volume', exact: true }));
|
||||
expect(screen.getByText('Completed restore volume per day')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /: 3.00 KB$/ })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Running').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Failed').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not draw completed activity or claim PBS restore coverage for an incomplete-only inventory', async () => {
|
||||
const { container } = mountInventory(true);
|
||||
const summary = await screen.findByText(/0 completed restore points · 1 running · 1 failed/);
|
||||
expect(summary).not.toHaveTextContent('with PBS snapshots');
|
||||
expect(screen.getByText(/No completed restore points with known dates/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('group', { name: 'Activity metric' })).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByRole('cell', { name: `${getRecoveryFullDateLabel('2026-10-04')} 2 entries` }),
|
||||
).toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByRole('link', { name: 'Coverage', exact: true }));
|
||||
expect(screen.queryByText(/completed restore points with known dates/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/0 completed restore points · 1 running · 1 failed/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps incomplete entries available under a completed day filter and when it is cleared', async () => {
|
||||
const { container } = mountInventory();
|
||||
await screen.findByText(/3 completed restore points/);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /: 3 restore points$/ }));
|
||||
await waitFor(() => expect(window.location.search).toContain('day=2026-10-04'));
|
||||
expect(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')).toHaveLength(5);
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Clear date filter' }));
|
||||
await waitFor(() => expect(window.location.search).not.toContain('day='));
|
||||
expect(screen.getByRole('button', { name: /: 3 restore points$/ })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false',
|
||||
);
|
||||
expect(container.querySelectorAll('[data-proxmox-backup-row="recoverable"]')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -425,6 +425,8 @@ describe('proxmoxBackupActivityPresentation', () => {
|
||||
[0, 'backup', '0 backups'],
|
||||
[1, 'backup', '1 backup'],
|
||||
[2, 'backup', '2 backups'],
|
||||
[1, 'restore point', '1 restore point'],
|
||||
[3, 'restore point', '3 restore points'],
|
||||
[1, 'snapshot', '1 snapshot'],
|
||||
[5, 'task', '5 tasks'],
|
||||
[2, 'archive', '2 archives'],
|
||||
|
||||
+2
@@ -500,6 +500,8 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () =>
|
||||
unknown: 0,
|
||||
withPBS: 0,
|
||||
recoverableArtifacts: 0,
|
||||
runningArtifacts: 0,
|
||||
failedArtifacts: 0,
|
||||
totalBytes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -598,3 +598,104 @@ describe('backup-date-evidence model', () => {
|
||||
expect(getRecoveryAgeBand(nowMs - 30 * 86400000 - 1, nowMs)).toBe('stale');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completed restore-point totals', () => {
|
||||
const nowMs = Date.parse('2026-05-26T08:00:00Z');
|
||||
const build = (
|
||||
pbsBackups: PBSBackup[],
|
||||
archives: StorageBackup[] = [],
|
||||
snapshots: GuestSnapshot[] = [],
|
||||
) =>
|
||||
buildProxmoxBackupRecoveryModel({
|
||||
workloads: [workload({})],
|
||||
pbsBackups,
|
||||
archives,
|
||||
snapshots,
|
||||
tasks: [],
|
||||
nowMs,
|
||||
});
|
||||
|
||||
it('keeps incomplete PBS and PVE entries visible without inflating completed counts or bytes', () => {
|
||||
const model = build(
|
||||
[
|
||||
pbsBackup({ id: 'complete', size: 100 }),
|
||||
pbsBackup({ id: 'running', size: 10_000, inProgress: true }),
|
||||
pbsBackup({
|
||||
id: 'failed',
|
||||
size: 20_000,
|
||||
inProgress: true,
|
||||
writeActivityObserved: true,
|
||||
writeActive: false,
|
||||
}),
|
||||
],
|
||||
[
|
||||
archive({ id: 'complete-file', size: 200 }),
|
||||
archive({ id: 'writing-file', size: 40_000, inProgress: true }),
|
||||
],
|
||||
[snapshot({ sizeBytes: 300 })],
|
||||
);
|
||||
expect(model.recoverableArtifacts).toHaveLength(6);
|
||||
expect(model.coverageRows[0].artifacts).toHaveLength(6);
|
||||
expect(model.coverageSummary).toMatchObject({
|
||||
recoverableArtifacts: 3,
|
||||
runningArtifacts: 2,
|
||||
failedArtifacts: 1,
|
||||
totalBytes: 600,
|
||||
withPBS: 1,
|
||||
});
|
||||
expect(model.coverageRows[0].latestBackup?.nativeId).toBe('complete');
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'does not count an incomplete-only PBS target when write activity is %s',
|
||||
(writeActive) => {
|
||||
const model = build([
|
||||
pbsBackup({
|
||||
inProgress: true,
|
||||
writeActivityObserved: true,
|
||||
writeActive,
|
||||
protected: true,
|
||||
verified: true,
|
||||
}),
|
||||
]);
|
||||
expect(model.coverageSummary).toMatchObject({
|
||||
recoverableArtifacts: 0,
|
||||
withPBS: 0,
|
||||
totalBytes: 0,
|
||||
runningArtifacts: writeActive ? 1 : 0,
|
||||
failedArtifacts: writeActive ? 0 : 1,
|
||||
});
|
||||
expect(model.recoverableArtifacts).toHaveLength(1);
|
||||
expect(model.recoverableArtifacts[0].verified).toBeUndefined();
|
||||
expect(model.coverageRows[0].latestBackup).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps unknown chronology and missing verification separate from completion', () => {
|
||||
const model = build([pbsBackup({ backupTime: 'not-a-date', verified: false, size: 123 })]);
|
||||
expect(model.coverageSummary).toMatchObject({
|
||||
recoverableArtifacts: 1,
|
||||
totalBytes: 123,
|
||||
withPBS: 1,
|
||||
});
|
||||
expect(model.coverageRows[0].latestBackup).toBeUndefined();
|
||||
expect(model.coverageRows[0].ageUnknown?.backup).toBe(true);
|
||||
expect(model.recoverableArtifacts[0].verified).toBe(false);
|
||||
});
|
||||
|
||||
it('moves the same observed entry into completed totals only when its producer reports completion', () => {
|
||||
const pending = build([pbsBackup({ inProgress: true, size: 100 })]);
|
||||
const complete = build([pbsBackup({ inProgress: false, size: 200 })]);
|
||||
expect(pending.coverageSummary).toMatchObject({
|
||||
recoverableArtifacts: 0,
|
||||
runningArtifacts: 1,
|
||||
totalBytes: 0,
|
||||
});
|
||||
expect(complete.coverageSummary).toMatchObject({
|
||||
recoverableArtifacts: 1,
|
||||
runningArtifacts: 0,
|
||||
totalBytes: 200,
|
||||
});
|
||||
expect(complete.recoverableArtifacts[0].id).toBe(pending.recoverableArtifacts[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,7 +148,8 @@ export interface BackupActivityTooltipRow {
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export type BackupActivityNoun = 'archive' | 'artifact' | 'backup' | 'task' | 'snapshot';
|
||||
export type BackupActivityNoun =
|
||||
'archive' | 'artifact' | 'backup' | 'task' | 'snapshot' | 'restore point';
|
||||
|
||||
function formatActivityValue(value: number, mode: BackupActivityMetricMode): string {
|
||||
if (mode === 'volume') return formatBytes(Math.max(0, value));
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface WorkloadCoverageRow {
|
||||
|
||||
export interface ProxmoxBackupRecoveryModel {
|
||||
coverageRows: WorkloadCoverageRow[];
|
||||
/** All observed entries, including incomplete artifacts retained for inspection. */
|
||||
recoverableArtifacts: RecoverableArtifact[];
|
||||
coverageSummary: {
|
||||
totalWorkloads: number;
|
||||
@@ -108,7 +109,10 @@ export interface ProxmoxBackupRecoveryModel {
|
||||
unprotected: number;
|
||||
unknown: number;
|
||||
withPBS: number;
|
||||
/** Completed restore points only; completion is not a successful restore or thaw. */
|
||||
recoverableArtifacts: number;
|
||||
runningArtifacts: number;
|
||||
failedArtifacts: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
}
|
||||
@@ -154,6 +158,11 @@ export function isBackupArtifact(artifact: RecoverableArtifact): boolean {
|
||||
return artifact.sourceKind !== 'snapshot';
|
||||
}
|
||||
|
||||
/** Incomplete artifacts remain evidence, but are not completed restore points. */
|
||||
export function isCompletedRecoveryArtifact(artifact: RecoverableArtifact): boolean {
|
||||
return !artifact.running && !artifact.failed;
|
||||
}
|
||||
|
||||
function parseTimestampMs(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const ms = Date.parse(value);
|
||||
@@ -511,7 +520,7 @@ export function selectWorkloadRecoveryArtifacts(
|
||||
> {
|
||||
// Completion remains independent of age. Keep every artifact listed, but
|
||||
// don't guess the latest when even one completed date cannot be ordered.
|
||||
const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed);
|
||||
const completed = artifacts.filter(isCompletedRecoveryArtifact);
|
||||
const select = (items: RecoverableArtifact[]) => {
|
||||
const unknown = items.some(
|
||||
(artifact) => getRecoveryAgeBand(artifact.createdMs, nowMs) === 'unknown',
|
||||
@@ -782,7 +791,8 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
});
|
||||
artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0));
|
||||
|
||||
const totalBytes = artifacts.reduce((sum, artifact) => sum + (artifact.size ?? 0), 0);
|
||||
const completedArtifacts = artifacts.filter(isCompletedRecoveryArtifact);
|
||||
const totalBytes = completedArtifacts.reduce((sum, artifact) => sum + (artifact.size ?? 0), 0);
|
||||
return {
|
||||
coverageRows,
|
||||
recoverableArtifacts: artifacts,
|
||||
@@ -792,8 +802,14 @@ export function buildProxmoxBackupRecoveryModel(
|
||||
attention: coverageRows.filter((row) => isCoverageAttention(row.posture)).length,
|
||||
unprotected: coverageRows.filter((row) => row.posture === 'unprotected').length,
|
||||
unknown: coverageRows.filter((row) => row.posture === 'unknown').length,
|
||||
withPBS: coverageRows.filter((row) => row.pbsCount > 0).length,
|
||||
recoverableArtifacts: artifacts.length,
|
||||
withPBS: coverageRows.filter((row) =>
|
||||
row.artifacts.some(
|
||||
(artifact) => artifact.sourceKind === 'pbs' && isCompletedRecoveryArtifact(artifact),
|
||||
),
|
||||
).length,
|
||||
recoverableArtifacts: completedArtifacts.length,
|
||||
runningArtifacts: artifacts.filter((artifact) => artifact.running).length,
|
||||
failedArtifacts: artifacts.filter((artifact) => artifact.failed).length,
|
||||
totalBytes,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user