mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-06-08 02:03:09 +08:00
feat: expand node artifact operations and retention
This commit is contained in:
275
webui/src/pages/NodeArtifacts.tsx
Normal file
275
webui/src/pages/NodeArtifacts.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { formatLocalDateTime } from '../utils/time';
|
||||
|
||||
function dataUrlForArtifact(artifact: any) {
|
||||
const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream';
|
||||
const content = String(artifact?.content_base64 || '').trim();
|
||||
if (!content) return '';
|
||||
return `data:${mime};base64,${content}`;
|
||||
}
|
||||
|
||||
function formatBytes(value: unknown) {
|
||||
const size = Number(value || 0);
|
||||
if (!Number.isFinite(size) || size <= 0) return '-';
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
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="text-xl md:text-2xl font-semibold">{t('nodeArtifacts')}</h1>
|
||||
<div className="text-sm text-zinc-500 mt-1">{t('nodeArtifactsHint')}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={exportURL()} className="rounded-xl border border-zinc-700 px-3 py-1.5 text-sm text-zinc-200">
|
||||
{t('export')}
|
||||
</a>
|
||||
<button onClick={loadArtifacts} className="brand-button px-3 py-1.5 rounded-xl text-sm text-white">
|
||||
{loading ? t('loading') : t('refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
|
||||
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="p-3 border-b border-zinc-800 space-y-2">
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-950/60 p-3 text-xs text-zinc-300 space-y-1">
|
||||
<div className="font-medium text-zinc-100">{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">
|
||||
<select value={nodeFilter} onChange={(e) => setNodeFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
|
||||
<option value="all">{t('allNodes')}</option>
|
||||
{nodes.map((node) => <option key={node} value={node}>{node}</option>)}
|
||||
</select>
|
||||
<select value={actionFilter} onChange={(e) => setActionFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
|
||||
<option value="all">{t('allActions')}</option>
|
||||
{actions.map((action) => <option key={action} value={action}>{action}</option>)}
|
||||
</select>
|
||||
<select value={kindFilter} onChange={(e) => setKindFilter(e.target.value)} className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-2 py-2 text-xs">
|
||||
<option value="all">{t('allKinds')}</option>
|
||||
{kinds.map((kind) => <option key={kind} value={kind}>{kind}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<input
|
||||
value={keepLatest}
|
||||
onChange={(e) => setKeepLatest(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder={t('nodeArtifactsKeepLatest')}
|
||||
className="rounded-xl bg-zinc-950/70 border border-zinc-800 px-3 py-2 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={pruneArtifacts}
|
||||
disabled={prunePending}
|
||||
className="rounded-xl border border-zinc-700 px-3 py-2 text-xs text-zinc-200 disabled:opacity-60"
|
||||
>
|
||||
{prunePending ? t('loading') : t('nodeArtifactsPrune')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto min-h-0">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="p-4 text-sm text-zinc-500">{t('nodeArtifactsEmpty')}</div>
|
||||
) : filteredItems.map((item, index) => {
|
||||
const active = String(selected?.id || '') === String(item?.id || '');
|
||||
return (
|
||||
<button
|
||||
key={String(item?.id || index)}
|
||||
onClick={() => setSelectedID(String(item?.id || ''))}
|
||||
className={`w-full text-left px-3 py-3 border-b border-zinc-800/60 hover:bg-zinc-800/20 ${active ? 'bg-indigo-500/15' : ''}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-zinc-100 truncate">{String(item?.name || item?.source_path || `artifact-${index + 1}`)}</div>
|
||||
<div className="text-xs text-zinc-400 truncate">{String(item?.node || '-')} · {String(item?.action || '-')} · {String(item?.kind || '-')}</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate">{formatLocalDateTime(item?.time)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="brand-card rounded-[28px] border border-zinc-800 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 text-xs text-zinc-400 uppercase tracking-wider">{t('nodeArtifactDetail')}</div>
|
||||
<div className="p-4 overflow-y-auto min-h-0 space-y-4 text-sm">
|
||||
{!selected ? (
|
||||
<div className="text-zinc-500">{t('nodeArtifactsEmpty')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-medium text-zinc-100">{String(selected?.name || selected?.source_path || 'artifact')}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">{String(selected?.node || '-')} · {String(selected?.action || '-')} · {formatLocalDateTime(selected?.time)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={downloadURL(String(selected?.id || ''))} className="rounded-xl border border-zinc-700 px-3 py-1.5 text-xs text-zinc-200">
|
||||
{t('download')}
|
||||
</a>
|
||||
<button onClick={() => deleteArtifact(String(selected?.id || ''))} className="rounded-xl bg-red-900/60 px-3 py-1.5 text-xs text-red-100">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div><div className="text-zinc-500 text-xs">{t('node')}</div><div>{String(selected?.node || '-')}</div></div>
|
||||
<div><div className="text-zinc-500 text-xs">{t('action')}</div><div>{String(selected?.action || '-')}</div></div>
|
||||
<div><div className="text-zinc-500 text-xs">{t('kind')}</div><div>{String(selected?.kind || '-')}</div></div>
|
||||
<div><div className="text-zinc-500 text-xs">{t('size')}</div><div>{formatBytes(selected?.size_bytes)}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500 break-all">
|
||||
{String(selected?.source_path || selected?.path || selected?.url || '-')}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const kind = String(selected?.kind || '').trim().toLowerCase();
|
||||
const mime = String(selected?.mime_type || '').trim().toLowerCase();
|
||||
const isImage = kind === 'image' || mime.startsWith('image/');
|
||||
const isVideo = kind === 'video' || mime.startsWith('video/');
|
||||
const dataUrl = dataUrlForArtifact(selected);
|
||||
if (isImage && dataUrl) {
|
||||
return <img src={dataUrl} alt={String(selected?.name || 'artifact')} className="max-h-[420px] rounded-2xl border border-zinc-800 object-contain bg-black/30" />;
|
||||
}
|
||||
if (isVideo && dataUrl) {
|
||||
return <video src={dataUrl} controls className="max-h-[420px] w-full rounded-2xl border border-zinc-800 bg-black/30" />;
|
||||
}
|
||||
if (String(selected?.content_text || '').trim() !== '') {
|
||||
return <pre className="rounded-2xl border border-zinc-800 bg-black/20 p-3 text-[12px] text-zinc-300 whitespace-pre-wrap overflow-auto max-h-[420px]">{String(selected?.content_text || '')}</pre>;
|
||||
}
|
||||
return <div className="text-zinc-500">{t('nodeArtifactPreviewUnavailable')}</div>;
|
||||
})()}
|
||||
|
||||
<pre className="rounded-2xl border border-zinc-800 bg-zinc-950/60 p-3 text-xs overflow-auto">{JSON.stringify(selected, null, 2)}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeArtifacts;
|
||||
Reference in New Issue
Block a user