256 lines
12 KiB
TypeScript
256 lines
12 KiB
TypeScript
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-records;V2 只展示、筛选和脱敏,不执行/停止/重试命令。</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>
|
||
)
|
||
}
|