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