Files
clawgo/webui/src/pages/NodeArtifacts.tsx
2026-03-12 10:24:40 +08:00

257 lines
12 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { Download, RefreshCw, Scissors, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { useAppContext } from '../context/AppContext';
import ArtifactPreviewCard from '../components/data-display/ArtifactPreviewCard';
import { FixedButton, FixedLinkButton, LinkButton } from '../components/ui/Button';
import CodeBlockPanel from '../components/data-display/CodeBlockPanel';
import EmptyState from '../components/data-display/EmptyState';
import { SelectField, TextField } from '../components/ui/FormControls';
import ListPanel from '../components/layout/ListPanel';
import SummaryListItem from '../components/list/SummaryListItem';
import { dataUrlForArtifact, formatArtifactBytes } from '../utils/artifacts';
import { formatLocalDateTime } from '../utils/time';
const NodeArtifacts: React.FC = () => {
const { t } = useTranslation();
const { q } = useAppContext();
const [searchParams, setSearchParams] = useSearchParams();
const [items, setItems] = useState<any[]>([]);
const [retentionSummary, setRetentionSummary] = useState<Record<string, any>>({});
const [selectedID, setSelectedID] = useState('');
const [loading, setLoading] = useState(false);
const [prunePending, setPrunePending] = useState(false);
const [nodeFilter, setNodeFilter] = useState(searchParams.get('node') || 'all');
const [actionFilter, setActionFilter] = useState(searchParams.get('action') || 'all');
const [kindFilter, setKindFilter] = useState(searchParams.get('kind') || 'all');
const [keepLatest, setKeepLatest] = useState('20');
const apiQuery = useMemo(() => {
const params = new URLSearchParams();
params.set('limit', '400');
if (nodeFilter !== 'all') params.set('node', nodeFilter);
if (actionFilter !== 'all') params.set('action', actionFilter);
if (kindFilter !== 'all') params.set('kind', kindFilter);
return params.toString();
}, [nodeFilter, actionFilter, kindFilter]);
useEffect(() => {
const next = new URLSearchParams();
if (nodeFilter !== 'all') next.set('node', nodeFilter);
if (actionFilter !== 'all') next.set('action', actionFilter);
if (kindFilter !== 'all') next.set('kind', kindFilter);
setSearchParams(next, { replace: true });
}, [nodeFilter, actionFilter, kindFilter, setSearchParams]);
const loadArtifacts = async () => {
setLoading(true);
try {
const query = q ? `${q}&${apiQuery}` : `?${apiQuery}`;
const r = await fetch(`/webui/api/node_artifacts${query}`);
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
const next = Array.isArray(j.items) ? j.items : [];
setRetentionSummary(j.artifact_retention && typeof j.artifact_retention === 'object' ? j.artifact_retention : {});
setItems(next);
if (next.length === 0) {
setSelectedID('');
} else if (!next.some((item: any) => String(item?.id || '') === selectedID)) {
setSelectedID(String(next[0]?.id || ''));
}
} catch (err) {
console.error(err);
setItems([]);
setSelectedID('');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadArtifacts();
}, [q, apiQuery]);
const nodes = useMemo(() => Array.from(new Set(items.map((item) => String(item?.node || '')).filter(Boolean))).sort(), [items]);
const actions = useMemo(() => Array.from(new Set(items.map((item) => String(item?.action || '')).filter(Boolean))).sort(), [items]);
const kinds = useMemo(() => Array.from(new Set(items.map((item) => String(item?.kind || '')).filter(Boolean))).sort(), [items]);
const filteredItems = items;
const selected = useMemo(() => {
return filteredItems.find((item) => String(item?.id || '') === selectedID) || filteredItems[0] || null;
}, [filteredItems, selectedID]);
async function deleteArtifact(id: string) {
const r = await fetch(`/webui/api/node_artifacts/delete${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (!r.ok) throw new Error(await r.text());
await loadArtifacts();
}
async function pruneArtifacts() {
setPrunePending(true);
try {
const keep = Math.max(0, Number.parseInt(keepLatest || '0', 10) || 0);
const r = await fetch(`/webui/api/node_artifacts/prune${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node: nodeFilter === 'all' ? '' : nodeFilter,
action: actionFilter === 'all' ? '' : actionFilter,
kind: kindFilter === 'all' ? '' : kindFilter,
keep_latest: keep,
limit: 1000,
}),
});
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
setRetentionSummary(j && typeof j === 'object' ? { ...retentionSummary, manual_pruned: j.pruned, manual_deleted_files: j.deleted_files, last_run_at: new Date().toISOString() } : retentionSummary);
await loadArtifacts();
} catch (err) {
console.error(err);
} finally {
setPrunePending(false);
}
}
function downloadURL(id: string) {
return `/webui/api/node_artifacts/download${q ? `${q}&id=${encodeURIComponent(id)}` : `?id=${encodeURIComponent(id)}`}`;
}
function exportURL() {
const query = q ? `${q}&${apiQuery}` : `?${apiQuery}`;
return `/webui/api/node_artifacts/export${query}`;
}
return (
<div className="h-full p-4 md:p-6 xl:p-8 flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="ui-text-primary text-xl md:text-2xl font-semibold">{t('nodeArtifacts')}</h1>
<div className="ui-text-muted text-sm mt-1">{t('nodeArtifactsHint')}</div>
</div>
<div className="flex items-center gap-2">
<FixedLinkButton href={exportURL()} label={t('export')}>
<Download className="w-4 h-4" />
</FixedLinkButton>
<FixedButton onClick={loadArtifacts} variant="primary" label={loading ? t('loading') : t('refresh')}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</FixedButton>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
<ListPanel>
<div className="ui-border-subtle p-3 border-b space-y-2">
<div className="ui-code-panel ui-text-secondary p-3 text-xs space-y-1">
<div className="ui-text-primary font-medium">{t('nodeArtifactsRetention')}</div>
<div>{t('nodeArtifactsRetentionKeepLatest')}: {Number(retentionSummary?.keep_latest || 0) || '-'}</div>
<div>{t('nodeArtifactsRetentionRetainDays')}: {Number(retentionSummary?.retain_days || 0)}</div>
<div>{t('nodeArtifactsRetentionPruned')}: {Number(retentionSummary?.pruned || retentionSummary?.manual_pruned || 0)}</div>
<div>{t('nodeArtifactsRetentionRemaining')}: {Number(retentionSummary?.remaining || filteredItems.length || 0)}</div>
</div>
<div className="grid grid-cols-1 gap-2">
<SelectField dense value={nodeFilter} onChange={(e) => setNodeFilter(e.target.value)}>
<option value="all">{t('allNodes')}</option>
{nodes.map((node) => <option key={node} value={node}>{node}</option>)}
</SelectField>
<SelectField dense value={actionFilter} onChange={(e) => setActionFilter(e.target.value)}>
<option value="all">{t('allActions')}</option>
{actions.map((action) => <option key={action} value={action}>{action}</option>)}
</SelectField>
<SelectField dense value={kindFilter} onChange={(e) => setKindFilter(e.target.value)}>
<option value="all">{t('allKinds')}</option>
{kinds.map((kind) => <option key={kind} value={kind}>{kind}</option>)}
</SelectField>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2">
<TextField
value={keepLatest}
onChange={(e) => setKeepLatest(e.target.value)}
inputMode="numeric"
placeholder={t('nodeArtifactsKeepLatest')}
dense
/>
<FixedButton onClick={pruneArtifacts} disabled={prunePending} variant="warning" label={prunePending ? t('loading') : t('nodeArtifactsPrune')}>
<Scissors className="w-4 h-4" />
</FixedButton>
</div>
</div>
<div className="overflow-y-auto min-h-0">
{filteredItems.length === 0 ? (
<EmptyState message={t('nodeArtifactsEmpty')} padded />
) : filteredItems.map((item, index) => {
const active = String(selected?.id || '') === String(item?.id || '');
return (
<SummaryListItem
key={String(item?.id || index)}
onClick={() => setSelectedID(String(item?.id || ''))}
active={active}
className="border-b py-3"
title={String(item?.name || item?.source_path || `artifact-${index + 1}`)}
subtitle={`${String(item?.node || '-')} · ${String(item?.action || '-')} · ${String(item?.kind || '-')}`}
meta={formatLocalDateTime(item?.time)}
/>
);
})}
</div>
</ListPanel>
<ListPanel header={<div className="ui-border-subtle ui-text-subtle px-3 py-2 border-b text-xs uppercase tracking-wider">{t('nodeArtifactDetail')}</div>}>
<div className="p-4 overflow-y-auto min-h-0 space-y-4 text-sm">
{!selected ? (
<EmptyState message={t('nodeArtifactsEmpty')} />
) : (
<>
<div className="flex items-center justify-between gap-3">
<div>
<div className="ui-text-primary text-lg font-medium">{String(selected?.name || selected?.source_path || 'artifact')}</div>
<div className="ui-text-muted text-xs mt-1">{String(selected?.node || '-')} · {String(selected?.action || '-')} · {formatLocalDateTime(selected?.time)}</div>
</div>
<div className="flex items-center gap-2">
<LinkButton href={downloadURL(String(selected?.id || ''))} size="xs">{t('download')}</LinkButton>
<FixedButton onClick={() => deleteArtifact(String(selected?.id || ''))} variant="danger" label={t('delete')}>
<Trash2 className="w-4 h-4" />
</FixedButton>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div><div className="ui-text-muted text-xs">{t('node')}</div><div className="ui-text-secondary">{String(selected?.node || '-')}</div></div>
<div><div className="ui-text-muted text-xs">{t('action')}</div><div className="ui-text-secondary">{String(selected?.action || '-')}</div></div>
<div><div className="ui-text-muted text-xs">{t('kind')}</div><div className="ui-text-secondary">{String(selected?.kind || '-')}</div></div>
<div><div className="ui-text-muted text-xs">{t('size')}</div><div className="ui-text-secondary">{formatArtifactBytes(selected?.size_bytes)}</div></div>
</div>
<div className="ui-text-muted text-xs break-all">
{String(selected?.source_path || selected?.path || selected?.url || '-')}
</div>
<ArtifactPreviewCard
artifact={selected}
dataUrl={dataUrlForArtifact(selected)}
fallbackName={String(selected?.name || 'artifact')}
formatBytes={formatArtifactBytes}
className="max-h-[420px]"
/>
{String(selected?.content_text || '').trim() === '' && !String(selected?.content_base64 || '').trim() ? (
<EmptyState message={t('nodeArtifactPreviewUnavailable')} />
) : null}
<CodeBlockPanel label={t('rawJson')} pre>{JSON.stringify(selected, null, 2)}</CodeBlockPanel>
</>
)}
</div>
</ListPanel>
</div>
</div>
);
};
export default NodeArtifacts;