fix(files): expose expiry and disable expired downloads

- return expiresAt and createdAt for scenario and run file metadata\n- keep download actions visible while preventing expired file access
This commit is contained in:
2026-04-21 16:46:33 +03:00
parent 86f9ac5603
commit 9580a7b2df
5 changed files with 115 additions and 52 deletions
+1
View File
@@ -308,6 +308,7 @@
"expiresAtLabel": "Expires At (optional)", "expiresAtLabel": "Expires At (optional)",
"createdAt": "Created", "createdAt": "Created",
"download": "Download", "download": "Download",
"expiredTooltip": "File is expired",
"empty": "No files uploaded.", "empty": "No files uploaded.",
"totalCount": "Total: {{count}} file(s)", "totalCount": "Total: {{count}} file(s)",
"artifactsTitle": "Artifacts" "artifactsTitle": "Artifacts"
+34 -6
View File
@@ -17,6 +17,7 @@ import { scenarios } from '../../api';
import { import {
AutoRefreshIndicator, AutoRefreshIndicator,
Badge, Badge,
Button,
Breadcrumbs, Breadcrumbs,
Card, Card,
CodeBlock, CodeBlock,
@@ -29,7 +30,7 @@ import {
type TableColumn, type TableColumn,
} from '../../ui'; } from '../../ui';
import type { BadgeVariant } from '../../ui'; import type { BadgeVariant } from '../../ui';
import { ChevronDown, ChevronRight, ClipboardList, Activity } from 'lucide-react'; import { ChevronDown, ChevronRight, ClipboardList, Activity, Download } from 'lucide-react';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
const RUN_STATUS_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = { const RUN_STATUS_VARIANT: Record<ScenarioRunStatus, BadgeVariant> = {
@@ -95,6 +96,16 @@ export function RunDetailPage() {
} }
}; };
const handleFileDownload = (url: string, filename: string) => {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
};
const isFileExpired = (file: FileMetadata) =>
file.expiresAt != null && new Date(file.expiresAt).getTime() <= Date.now();
const manualRefresh = () => { const manualRefresh = () => {
if (!id || !runId) return; if (!id || !runId) return;
runs runs
@@ -240,6 +251,7 @@ export function RunDetailPage() {
), ),
render: (s) => { render: (s) => {
const isExpanded = expandAll || expandedStepIds.has(s.id); const isExpanded = expandAll || expandedStepIds.has(s.id);
return ( return (
<div> <div>
<div <div
@@ -481,11 +493,27 @@ export function RunDetailPage() {
{ {
key: 'download', key: 'download',
header: '', header: '',
render: (f) => ( render: (f) => {
<a href={runFiles.contentUrl(id!, runId!, f.id)} download={f.name}> const expired = isFileExpired(f);
{t('files.download')} const tooltip = expired ? t('files.expiredTooltip') : t('files.download');
</a>
), return (
<span title={tooltip}>
<Button
variant="secondary"
size="sm"
title={tooltip}
aria-label={tooltip}
disabled={expired}
onClick={() =>
handleFileDownload(runFiles.contentUrl(id!, runId!, f.id), f.name)
}
>
<Download size={14} />
</Button>
</span>
);
},
}, },
] satisfies TableColumn<FileMetadata>[] ] satisfies TableColumn<FileMetadata>[]
} }
@@ -7,6 +7,7 @@ import {
Pencil, Pencil,
Plus, Plus,
History, History,
Download,
Trash2, Trash2,
Upload, Upload,
GripVertical, GripVertical,
@@ -201,6 +202,16 @@ export function ScenarioDetailPage() {
} }
}; };
const handleFileDownload = (url: string, filename: string) => {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
};
const isFileExpired = (file: FileMetadata) =>
file.expiresAt != null && new Date(file.expiresAt).getTime() <= Date.now();
async function handleUpload(e: SubmitEvent) { async function handleUpload(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!id || !uploadFile) return; if (!id || !uploadFile) return;
@@ -649,53 +660,66 @@ export function ScenarioDetailPage() {
</Button> </Button>
</div> </div>
<Card> <Table<FileMetadata>
<Table<FileMetadata> loading={filesLoading}
loading={filesLoading} data={files}
data={files} rowKey={(f) => f.id}
rowKey={(f) => f.id} columns={
columns={ [
[ {
{ key: 'name',
key: 'name', header: t('files.name'),
header: t('files.name'), render: (f) => f.name,
render: (f) => f.name, },
{
key: 'mimeType',
header: t('files.mimeType'),
render: (f) => f.mimeType,
},
{
key: 'size',
header: t('files.size'),
render: (f) => `${(f.size / 1024).toFixed(1)} KB`,
},
{
key: 'expiresAt',
header: t('files.expiresAt'),
render: (f) => (f.expiresAt ? <Timestamp value={f.expiresAt} /> : '—'),
},
{
key: 'createdAt',
header: t('files.createdAt'),
render: (f) => <Timestamp value={f.createdAt} />,
},
{
key: 'download',
header: '',
render: (f) => {
const expired = isFileExpired(f);
const tooltip = expired ? t('files.expiredTooltip') : t('files.download');
return (
<span title={tooltip}>
<Button
variant="secondary"
size="sm"
title={tooltip}
aria-label={tooltip}
disabled={expired}
onClick={() =>
handleFileDownload(scenarioFiles.contentUrl(id!, f.id), f.name)
}
>
<Download size={14} />
</Button>
</span>
);
}, },
{ },
key: 'mimeType', ] satisfies TableColumn<FileMetadata>[]
header: t('files.mimeType'), }
render: (f) => f.mimeType, emptyMessage={t('files.empty')}
}, />
{
key: 'size',
header: t('files.size'),
render: (f) => `${(f.size / 1024).toFixed(1)} KB`,
},
{
key: 'expiresAt',
header: t('files.expiresAt'),
render: (f) => (f.expiresAt ? <Timestamp value={f.expiresAt} /> : '—'),
},
{
key: 'createdAt',
header: t('files.createdAt'),
render: (f) => <Timestamp value={f.createdAt} />,
},
{
key: 'download',
header: '',
render: (f) => (
<a href={scenarioFiles.contentUrl(id!, f.id)} download={f.name}>
{t('files.download')}
</a>
),
},
] satisfies TableColumn<FileMetadata>[]
}
emptyMessage={t('files.empty')}
/>
<p>{t('files.totalCount', { count: filesTotal })}</p>
</Card>
</div> </div>
{/* ── Upload modal ─────────────────────────────────────────────── */} {/* ── Upload modal ─────────────────────────────────────────────── */}
+4
View File
@@ -19,6 +19,8 @@ export interface FileMetadata {
mimeType: string; mimeType: string;
size: number; size: number;
sha256: string; sha256: string;
expiresAt: Date | null;
createdAt: Date;
path: string; path: string;
} }
@@ -271,6 +273,8 @@ export class FileStorageService implements OnModuleInit {
mimeType: file.mimeType, mimeType: file.mimeType,
size: file.size, size: file.size,
sha256: file.sha256, sha256: file.sha256,
expiresAt: file.expiresAt,
createdAt: file.createdAt,
path: this.getAbsolutePath(file.filePath), path: this.getAbsolutePath(file.filePath),
}; };
} }
+6
View File
@@ -732,6 +732,9 @@ export class ScenarioService {
name: item.name, name: item.name,
mimeType: item.mimeType, mimeType: item.mimeType,
size: item.size, size: item.size,
sha256: item.sha256,
expiresAt: item.expiresAt,
createdAt: item.createdAt,
})), })),
total: result.total, total: result.total,
}; };
@@ -790,6 +793,9 @@ export class ScenarioService {
name: item.name, name: item.name,
mimeType: item.mimeType, mimeType: item.mimeType,
size: item.size, size: item.size,
sha256: item.sha256,
expiresAt: item.expiresAt,
createdAt: item.createdAt,
})), })),
total: result.total, total: result.total,
}; };