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)",
"createdAt": "Created",
"download": "Download",
"expiredTooltip": "File is expired",
"empty": "No files uploaded.",
"totalCount": "Total: {{count}} file(s)",
"artifactsTitle": "Artifacts"
+34 -6
View File
@@ -17,6 +17,7 @@ import { scenarios } from '../../api';
import {
AutoRefreshIndicator,
Badge,
Button,
Breadcrumbs,
Card,
CodeBlock,
@@ -29,7 +30,7 @@ import {
type TableColumn,
} 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';
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 = () => {
if (!id || !runId) return;
runs
@@ -240,6 +251,7 @@ export function RunDetailPage() {
),
render: (s) => {
const isExpanded = expandAll || expandedStepIds.has(s.id);
return (
<div>
<div
@@ -481,11 +493,27 @@ export function RunDetailPage() {
{
key: 'download',
header: '',
render: (f) => (
<a href={runFiles.contentUrl(id!, runId!, f.id)} download={f.name}>
{t('files.download')}
</a>
),
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(runFiles.contentUrl(id!, runId!, f.id), f.name)
}
>
<Download size={14} />
</Button>
</span>
);
},
},
] satisfies TableColumn<FileMetadata>[]
}
@@ -7,6 +7,7 @@ import {
Pencil,
Plus,
History,
Download,
Trash2,
Upload,
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) {
e.preventDefault();
if (!id || !uploadFile) return;
@@ -649,53 +660,66 @@ export function ScenarioDetailPage() {
</Button>
</div>
<Card>
<Table<FileMetadata>
loading={filesLoading}
data={files}
rowKey={(f) => f.id}
columns={
[
{
key: 'name',
header: t('files.name'),
render: (f) => f.name,
<Table<FileMetadata>
loading={filesLoading}
data={files}
rowKey={(f) => f.id}
columns={
[
{
key: 'name',
header: t('files.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',
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) => (
<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>
},
] satisfies TableColumn<FileMetadata>[]
}
emptyMessage={t('files.empty')}
/>
</div>
{/* ── Upload modal ─────────────────────────────────────────────── */}