Files
Nexus/frontend-v2/src/features/dashboard/components/ExecutionRecordsPage.tsx
T
2026-07-08 22:31:31 +08:00

256 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'
import type { ColumnDef } from '@tanstack/react-table'
import { Activity, ArrowUpRight, Clock3, FileClock, Filter, LockKeyhole, Search, Server, ShieldCheck, TerminalSquare } from 'lucide-react'
import { Badge } from '~components/ui/Badge'
import { Panel } from '~components/ui/Panel'
import { cn } from '@/lib/utils'
import { redactSensitiveText } from '@/lib/redaction'
import { fetchExecutionRecordsData } from '../api/dashboardApi'
import type { ExecutionRecordItem, ExecutionRecordKind, ExecutionRecordStatus } from '../api/dashboardApi'
interface ExecutionRecordsPageProps {
mode: 'live' | 'fallback'
}
type FilterKind = 'all' | ExecutionRecordKind
type StatusTone = 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
function statusTone(status: ExecutionRecordStatus): StatusTone {
if (status === 'completed') return 'green'
if (status === 'running') return 'blue'
if (status === 'partial') return 'amber'
if (status === 'failed') return 'red'
if (status === 'cancelled') return 'slate'
return 'purple'
}
function statusLabel(status: ExecutionRecordStatus): string {
const labels: Record<string, string> = {
completed: '已完成',
running: '运行中',
failed: '失败',
partial: '部分完成',
cancelled: '已取消',
}
return labels[String(status)] || String(status || '未知')
}
function kindLabel(kind: ExecutionRecordKind): string {
return kind === 'script' ? '脚本执行' : '服务器批量任务'
}
function formatDateTime(value?: string | null): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
function redactText(value?: string | null): string {
return redactSensitiveText(value, '-', 120)
}
function durationText(startedAt?: string | null, completedAt?: string | null): string {
if (!startedAt) return '-'
const start = Date.parse(startedAt)
const end = completedAt ? Date.parse(completedAt) : Date.now()
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return '-'
const seconds = Math.round((end - start) / 1000)
if (seconds < 60) return String(seconds) + 's'
const minutes = Math.round(seconds / 60)
if (minutes < 60) return String(minutes) + 'm'
return String(Math.round(minutes / 60)) + 'h'
}
function metricValue(items: ExecutionRecordItem[], matcher: (item: ExecutionRecordItem) => boolean): number {
return items.filter(matcher).length
}
export function ExecutionRecordsPage({ mode }: ExecutionRecordsPageProps): JSX.Element {
const [query, setQuery] = useState('')
const [kind, setKind] = useState<FilterKind>('all')
const [status, setStatus] = useState<string>('all')
const { data } = useSuspenseQuery({
queryKey: ['execution-records-v2'],
queryFn: fetchExecutionRecordsData,
staleTime: 20_000,
})
const filteredItems = useMemo(() => {
const text = query.trim().toLowerCase()
return data.items.filter((item) => {
const kindMatched = kind === 'all' || item.kind === kind
const statusMatched = status === 'all' || item.status === status
const textMatched = !text || [item.label, item.operator, item.op, item.command, String(item.id)].some((value) => String(value || '').toLowerCase().includes(text))
return kindMatched && statusMatched && textMatched
})
}, [data.items, kind, query, status])
const statusOptions = useMemo(() => Array.from(new Set(data.items.map((item) => String(item.status || 'unknown')))), [data.items])
const columns = useMemo<ColumnDef<ExecutionRecordItem>[]>(() => [
{
accessorKey: 'label',
header: '任务',
cell: ({ row }) => {
const item = row.original
return (
<div className="min-w-72">
<div className="flex items-center gap-2">
<Badge tone={item.kind === 'script' ? 'purple' : 'blue'}>{kindLabel(item.kind)}</Badge>
<span className="text-sm font-black text-white">#{item.id}</span>
</div>
<div className="mt-2 text-sm font-black text-white">{redactText(item.label)}</div>
<div className="mt-1 text-xs font-semibold text-slate-500">命令/操作:{redactText(item.op || item.command)}</div>
</div>
)
},
},
{
accessorKey: 'status',
header: '状态',
cell: ({ row }) => <Badge tone={statusTone(row.original.status)} dot>{statusLabel(row.original.status)}</Badge>,
},
{
accessorKey: 'progress',
header: '进度',
cell: ({ row }) => <span className="text-sm font-black text-cyan-200">{row.original.progress || '-'}</span>,
},
{
accessorKey: 'server_count',
header: '服务器',
cell: ({ row }) => <span className="text-sm font-bold text-slate-300">{row.original.server_count ?? 0} </span>,
},
{
accessorKey: 'operator',
header: '操作者',
cell: ({ row }) => <span className="text-sm font-semibold text-slate-400">{row.original.operator || '-'}</span>,
},
{
accessorKey: 'started_at',
header: '时间',
cell: ({ row }) => (
<div className="text-xs font-semibold leading-5 text-slate-400">
<div>{formatDateTime(row.original.started_at)}</div>
<div className="text-slate-600">耗时 {durationText(row.original.started_at, row.original.completed_at)}</div>
</div>
),
},
], [])
const table = useReactTable({
data: filteredItems,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
const cards = [
{ label: '执行记录', value: String(data.total), sub: data.mode === 'live' ? '来自后端实时 API' : '安全降级演示', icon: FileClock, tone: 'text-cyan-300' },
{ label: '运行中', value: String(metricValue(data.items, (item) => item.status === 'running')), sub: '只读观察,不在 V2 停止任务', icon: Activity, tone: 'text-blue-300' },
{ label: '异常/部分完成', value: String(metricValue(data.items, (item) => item.status === 'failed' || item.status === 'partial')), sub: '详情深查回旧后台', icon: ShieldCheck, tone: 'text-amber-300' },
{ label: '涉及服务器', value: String(data.items.reduce((sum, item) => sum + (item.server_count || 0), 0)), sub: '合计 server_count', icon: Server, tone: 'text-emerald-300' },
]
return (
<div id="executions" className="space-y-6 px-5 pb-5 lg:px-10 lg:pb-10">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<Panel key={card.label} className="p-5">
<div className="flex items-center justify-between">
<div className="text-xs font-black uppercase tracking-[0.16em] text-slate-500">{card.label}</div>
<card.icon className={cn('h-5 w-5', card.tone)} />
</div>
<div className="mt-3 text-3xl font-black tracking-[-0.05em] text-white">{card.value}</div>
<div className="mt-2 text-sm font-semibold text-slate-500">{card.sub}</div>
</Panel>
))}
</section>
<Panel className="overflow-hidden p-5">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 pb-5">
<div>
<div className="flex items-center gap-3">
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-violet-400/15 text-violet-200"><TerminalSquare className="h-5 w-5" /></div>
<div>
<h2 className="text-xl font-black tracking-[-0.04em] text-white">执行记录只读中心</h2>
<p className="mt-1 text-sm font-semibold text-slate-500">接入 /api/execution-recordsV2 只展示、筛选和脱敏,不执行/停止/重试命令。</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge tone={data.mode === 'live' ? 'green' : 'amber'} dot>{data.mode === 'live' ? '实时 API' : '降级数据'}</Badge>
<Badge tone={mode === 'live' ? 'blue' : 'slate'}>Dashboard {mode}</Badge>
<Badge tone="purple"><LockKeyhole className="mr-1 h-3 w-3" />敏感字段已隐藏</Badge>
</div>
</div>
{data.warning ? <div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100">{data.warning}</div> : null}
<div className="mt-5 grid gap-3 xl:grid-cols-[1fr_auto_auto]">
<label className="focus-within:ring-nexus flex items-center gap-3 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-semibold text-slate-500">
<Search className="h-4 w-4" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务名 / 操作者 / 操作 / ID" className="w-full bg-transparent text-slate-100 outline-none placeholder:text-slate-600" />
</label>
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
<Filter className="h-4 w-4" />
<select value={kind} onChange={(event) => setKind(event.target.value as FilterKind)} className="bg-transparent text-slate-100 outline-none">
<option value="all">全部类型</option>
<option value="script">脚本执行</option>
<option value="server_batch">服务器批量任务</option>
</select>
</label>
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 text-sm font-bold text-slate-400">
<Clock3 className="h-4 w-4" />
<select value={status} onChange={(event) => setStatus(event.target.value)} className="bg-transparent text-slate-100 outline-none">
<option value="all">全部状态</option>
{statusOptions.map((option) => <option key={option} value={option}>{statusLabel(option)}</option>)}
</select>
</label>
</div>
<div className="mt-5 overflow-x-auto rounded-3xl border border-white/10">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/[0.03]">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-5 py-4 text-left text-xs font-black uppercase tracking-[0.14em] text-slate-500">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-white/10 bg-slate-950/20">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="transition hover:bg-white/[0.03]">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-5 py-4 align-top">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{filteredItems.length === 0 ? <div className="px-5 py-12 text-center text-sm font-bold text-slate-500">没有符合条件的执行记录</div> : null}
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-cyan-300/10 bg-cyan-400/5 p-4">
<div className="text-sm font-semibold leading-6 text-slate-400">
写操作仍保留在旧后台:执行脚本、停止任务、重试任务、查看完整日志都从旧后台进入,避免 V2 早期迁移误触发远程 shell
</div>
<a href="/app/scripts" className="focus-ring inline-flex items-center gap-2 rounded-2xl border border-cyan-300/25 bg-cyan-400/15 px-4 py-3 text-sm font-black text-cyan-100">
去旧后台处理 <ArrowUpRight className="h-4 w-4" />
</a>
</div>
</Panel>
</div>
)
}