Files
Nexus/frontend-v2/src/features/dashboard/components/ExecutionRecordsPage.tsx
T

256 lines
12 KiB
TypeScript
Raw Normal View History

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>
)
}