Merge remote-tracking branch 'origin/aether-rust-pioneer' into codex/async-cleanup-records

# Conflicts:
#	apps/aether-gateway/src/maintenance/mod.rs
#	apps/aether-gateway/src/maintenance/runtime/runners.rs
This commit is contained in:
fawney19
2026-05-10 00:28:39 +08:00
315 changed files with 19750 additions and 3183 deletions

View File

@@ -28,12 +28,12 @@
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Loader2
class="w-5 h-5 text-blue-500"
:class="{ 'animate-spin': (stats?.processing_count ?? 0) > 0 }"
:class="{ 'animate-spin': runningCount > 0 }"
/>
</div>
<div>
<p class="text-2xl font-bold">
{{ stats?.processing_count ?? stats?.by_status?.processing ?? '-' }}
{{ runningCount || '-' }}
</p>
<p class="text-xs text-muted-foreground">
处理中
@@ -51,7 +51,7 @@
</div>
<div>
<p class="text-2xl font-bold">
{{ stats?.by_status?.completed ?? '-' }}
{{ stats?.by_status?.succeeded ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
已完成
@@ -69,10 +69,10 @@
</div>
<div>
<p class="text-2xl font-bold">
{{ stats?.today_count ?? '-' }}
{{ stats?.registered_tasks ?? '-' }}
</p>
<p class="text-xs text-muted-foreground">
今日任务
已注册
</p>
</div>
</div>
@@ -102,14 +102,17 @@
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="submitted">
已提交
<SelectItem value="queued">
排队中
</SelectItem>
<SelectItem value="processing">
处理
<SelectItem value="running">
运行
</SelectItem>
<SelectItem value="completed">
已完成
<SelectItem value="retrying">
重试中
</SelectItem>
<SelectItem value="succeeded">
成功
</SelectItem>
<SelectItem value="failed">
失败
@@ -117,13 +120,16 @@
<SelectItem value="cancelled">
已取消
</SelectItem>
<SelectItem value="skipped">
已跳过
</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 -->
<Input
v-model="filterModel"
type="text"
placeholder="模型..."
placeholder="任务 Key..."
class="w-32 h-8 text-xs"
/>
<!-- 刷新按钮 -->
@@ -207,13 +213,13 @@
v-if="isVideoTask(task)"
class="w-4 h-4 text-muted-foreground shrink-0"
/>
<span class="font-medium text-sm truncate">{{ task.model }}</span>
<span class="font-medium text-sm truncate">{{ displayTaskName(task) }}</span>
</div>
<p
class="text-xs text-muted-foreground truncate max-w-[280px]"
:title="task.prompt"
:title="displayTaskDescription(task)"
>
{{ task.prompt }}
{{ displayTaskDescription(task) }}
</p>
</div>
</TableCell>
@@ -229,7 +235,7 @@
</div>
<div class="flex items-center gap-1.5 text-muted-foreground">
<Server class="w-3 h-3" />
<span class="truncate max-w-[100px]">{{ task.provider_name }}</span>
<span class="truncate max-w-[100px]">{{ displayTaskSource(task) }}</span>
</div>
</div>
</TableCell>
@@ -243,7 +249,7 @@
{{ getStatusLabel(task.status) }}
</Badge>
<div
v-if="task.progress_percent > 0 && task.status === 'processing'"
v-if="task.progress_percent > 0 && isRunningStatus(task.status)"
class="w-full"
>
<div class="flex items-center gap-2">
@@ -262,11 +268,11 @@
<TableCell>
<div class="text-xs space-y-0.5 text-muted-foreground">
<div
v-if="task.duration_seconds"
v-if="task.duration_seconds || task.attempt"
class="flex items-center gap-1"
>
<Timer class="w-3 h-3" />
<span>{{ task.duration_seconds }}s</span>
<span>{{ task.duration_seconds ? `${task.duration_seconds}s` : `${task.attempt}/${task.max_attempts ?? 1}` }}</span>
</div>
<div v-if="task.resolution">
{{ task.resolution }}
@@ -284,11 +290,11 @@
<span>{{ formatDate(task.created_at) }}</span>
</div>
<div
v-if="task.completed_at"
v-if="finishTime(task)"
class="flex items-center gap-1.5 text-green-600 dark:text-green-400"
>
<CheckCircle class="w-3 h-3" />
<span>{{ formatDate(task.completed_at) }}</span>
<span>{{ formatDate(finishTime(task)) }}</span>
</div>
</div>
</TableCell>
@@ -305,6 +311,7 @@
<Eye class="w-4 h-4" />
</Button>
<Button
v-if="isVideoTask(task)"
variant="ghost"
size="icon"
class="h-7 w-7"
@@ -337,7 +344,7 @@
v-if="isVideoTask(task)"
class="w-4 h-4 text-muted-foreground shrink-0"
/>
<span class="font-medium text-sm truncate">{{ task.model }}</span>
<span class="font-medium text-sm truncate">{{ displayTaskName(task) }}</span>
</div>
<Badge
:variant="getStatusVariant(task.status)"
@@ -349,7 +356,7 @@
<!-- 进度条如果有 -->
<div
v-if="task.progress_percent > 0 && task.status === 'processing'"
v-if="task.progress_percent > 0 && isRunningStatus(task.status)"
class="space-y-1"
>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
@@ -365,7 +372,7 @@
<!-- Prompt -->
<p class="text-sm text-muted-foreground line-clamp-2">
{{ task.prompt }}
{{ displayTaskDescription(task) }}
</p>
<!-- 信息网格 -->
@@ -379,24 +386,25 @@
</div>
<div class="flex items-center gap-1.5 text-muted-foreground">
<Server class="w-3 h-3" />
<span class="truncate">{{ task.provider_name }}</span>
<span class="truncate">{{ displayTaskSource(task) }}</span>
</div>
<div class="flex items-center gap-1.5 text-muted-foreground">
<Clock class="w-3 h-3" />
<span>{{ formatDate(task.created_at) }}</span>
</div>
<div
v-if="task.duration_seconds"
v-if="task.duration_seconds || task.attempt"
class="flex items-center gap-1.5 text-muted-foreground"
>
<Timer class="w-3 h-3" />
<span>{{ task.duration_seconds }}s</span>
<span>{{ task.duration_seconds ? `${task.duration_seconds}s` : `${task.attempt}/${task.max_attempts ?? 1}` }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-end gap-2">
<Button
v-if="isVideoTask(task)"
variant="outline"
size="sm"
class="h-7 text-xs"
@@ -459,7 +467,7 @@
v-if="isVideoTask(selectedTask)"
class="w-3.5 h-3.5 mr-1"
/>
<span>{{ selectedTask.model }}</span>
<span>{{ displayTaskName(selectedTask) }}</span>
</div>
<Badge :variant="getStatusVariant(selectedTask.status)">
{{ getStatusLabel(selectedTask.status) }}
@@ -503,11 +511,11 @@
<span>用户: {{ selectedTask.username }}</span>
</template>
<span class="opacity-40">|</span>
<span>Provider: {{ selectedTask.provider_name }}</span>
<span>{{ displayTaskSource(selectedTask) }}</span>
</div>
<!-- 进度条 -->
<div
v-if="selectedTask.progress_percent > 0 && selectedTask.status === 'processing'"
v-if="selectedTask.progress_percent > 0 && isRunningStatus(selectedTask.status)"
class="mt-3"
>
<div class="flex items-center gap-3">
@@ -641,7 +649,7 @@
<!-- 任务完成但无视频 -->
<div
v-else-if="selectedTask.status === 'completed'"
v-else-if="isSucceededStatus(selectedTask.status) && isVideoTask(selectedTask)"
class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800 text-center"
>
<Video class="w-8 h-8 mx-auto mb-2 text-amber-500" />
@@ -660,14 +668,14 @@
variant="outline"
size="sm"
class="h-6 px-2 text-xs"
@click="copyToClipboard(selectedTask.prompt)"
@click="copyToClipboard(displayTaskDescription(selectedTask))"
>
<Copy class="w-3 h-3 mr-1" />
复制
</Button>
</div>
<div class="p-3 bg-muted/50 rounded-lg border border-border/60 text-sm whitespace-pre-wrap break-words max-h-32 overflow-y-auto leading-relaxed">
{{ selectedTask.prompt }}
{{ displayTaskDescription(selectedTask) }}
</div>
</div>
@@ -738,7 +746,7 @@
轮询
</p>
<p class="text-sm font-medium">
{{ selectedTask.poll_count }} / {{ selectedTask.max_poll_count }}
{{ selectedTask.poll_count ?? selectedTask.attempt ?? 0 }} / {{ selectedTask.max_poll_count ?? selectedTask.max_attempts ?? 1 }}
</p>
</div>
<div class="p-3 bg-muted/30 rounded-lg">
@@ -746,7 +754,7 @@
重试
</p>
<p class="text-sm font-medium">
{{ selectedTask.retry_count }} / {{ selectedTask.max_retries }}
{{ selectedTask.retry_count ?? 0 }} / {{ selectedTask.max_retries ?? selectedTask.max_attempts ?? 1 }}
</p>
</div>
<div class="p-3 bg-muted/30 rounded-lg">
@@ -754,7 +762,7 @@
轮询间隔
</p>
<p class="text-sm font-medium">
{{ selectedTask.poll_interval_seconds }}s
{{ selectedTask.poll_interval_seconds ? `${selectedTask.poll_interval_seconds}s` : selectedTask.trigger ?? '-' }}
</p>
</div>
<div
@@ -780,13 +788,13 @@
<span>{{ formatTimeWithMs(selectedTask.created_at) }}</span>
<span class="time-arrow-container">
<span
v-if="selectedTask.completed_at"
v-if="finishTime(selectedTask)"
class="time-duration"
>+{{ calcDuration(selectedTask.created_at, selectedTask.completed_at) }}</span>
>+{{ calcDuration(selectedTask.created_at, finishTime(selectedTask) || selectedTask.created_at) }}</span>
<span class="time-arrow"></span>
</span>
<template v-if="selectedTask.completed_at">
<span>{{ formatTimeWithMs(selectedTask.completed_at) }}</span>
<template v-if="finishTime(selectedTask)">
<span>{{ formatTimeWithMs(finishTime(selectedTask)) }}</span>
</template>
<span
v-else
@@ -917,10 +925,47 @@ let overviewRefreshInFlight = false
// 使用记录详情抽屉状态
const usageDetailOpen = ref(false)
const usageRequestId = ref<string | null>(null)
const runningCount = computed(() => {
return stats.value?.running_count
?? stats.value?.processing_count
?? stats.value?.by_status?.running
?? stats.value?.by_status?.processing
?? 0
})
// 判断是否为视频任务
function isVideoTask(task: AsyncTaskItem): boolean {
return task.task_type === 'video' || !!task.video_url || !!task.duration_seconds
return task.task_type === 'video' || !!task.video_url || !!task.duration_seconds || task.task_key === 'video.task.poller'
}
function displayTaskName(task: AsyncTaskItem | AsyncTaskDetail): string {
return task.model || task.task_key || task.id
}
function displayTaskDescription(task: AsyncTaskItem | AsyncTaskDetail): string {
if (task.prompt) return task.prompt
if (task.progress_message) return task.progress_message
if (task.error_message) return task.error_message
if (task.payload) return formatJson(task.payload)
return task.trigger || task.kind || '-'
}
function displayTaskSource(task: AsyncTaskItem | AsyncTaskDetail): string {
if (task.provider_name) return `Provider: ${task.provider_name}`
if (task.kind || task.trigger) return `${task.kind ?? 'task'} / ${task.trigger ?? '-'}`
return task.owner_instance || '-'
}
function finishTime(task: AsyncTaskItem | AsyncTaskDetail): string | null {
return task.finished_at || task.completed_at || null
}
function isRunningStatus(status: string): boolean {
return ['running', 'retrying', 'processing', 'submitted', 'pending', 'queued'].includes(status)
}
function isSucceededStatus(status: string): boolean {
return status === 'succeeded' || status === 'completed'
}
// 获取任务列表
@@ -929,7 +974,7 @@ async function fetchTasks() {
try {
const response = await asyncTasksApi.list({
status: filterStatus.value !== 'all' ? filterStatus.value as AsyncTaskStatus : undefined,
model: filterModel.value || undefined,
task_key: filterModel.value || undefined,
page: currentPage.value,
page_size: pageSize.value,
})
@@ -1085,6 +1130,7 @@ async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
// 状态相关
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'succeeded':
case 'completed':
return 'default'
case 'failed':
@@ -1101,16 +1147,20 @@ function getStatusLabel(status: string): string {
pending: '待处理',
submitted: '已提交',
queued: '排队中',
running: '运行中',
retrying: '重试中',
processing: '处理中',
succeeded: '成功',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
skipped: '已跳过',
}
return labels[status] || status
}
function canCancel(status: string): boolean {
return ['pending', 'submitted', 'queued', 'processing'].includes(status)
return ['pending', 'submitted', 'queued', 'processing', 'running', 'retrying'].includes(status)
}
// 格式化日期(简短格式,用于表格列表)
@@ -1229,7 +1279,7 @@ watch(filterModel, () => {
// 检查是否有进行中的任务
const hasProcessingTasks = computed(() => {
return tasks.value.some(t =>
['pending', 'submitted', 'queued', 'processing'].includes(t.status)
['pending', 'submitted', 'queued', 'processing', 'running', 'retrying'].includes(t.status)
)
})

View File

@@ -131,6 +131,7 @@
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[28px] min-w-[28px] max-w-[28px] h-12 p-0 pl-2" />
<TableHead class="w-[160px] h-12 font-semibold">
名称
</TableHead>
@@ -173,141 +174,177 @@
<TableHead class="w-[160px] h-12 font-semibold">
最后心跳
</TableHead>
<TableHead class="w-[80px] h-12 font-semibold text-center">
<TableHead class="w-[140px] h-12 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
<template
v-for="node in paginatedNodes"
:key="node.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="py-4">
<div class="flex items-center gap-1.5">
<span class="text-sm font-semibold">{{ node.name }}</span>
<Badge
v-if="node.is_manual"
variant="outline"
class="text-[10px] px-1.5 py-0"
<TableRow
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
:class="isNodeExpanded(node.id) ? 'bg-muted/20' : ''"
>
<TableCell class="w-[28px] min-w-[28px] max-w-[28px] p-0 pl-2 text-center">
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
:title="isNodeExpanded(node.id) ? '收起数据' : '展开数据'"
@click="toggleNodeDetails(node)"
>
手动
</Badge>
<Badge
v-if="node.tunnel_mode"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
Tunnel
</Badge>
<Badge
v-if="nodeSchedulingBadge(node)"
:variant="nodeSchedulingBadge(node)!.variant"
class="text-[10px] px-1.5 py-0"
>
{{ nodeSchedulingBadge(node)!.label }}
</Badge>
<HardwareTooltip :node="node" />
</div>
</TableCell>
<TableCell class="py-4">
<code class="text-xs text-muted-foreground">{{ nodeAddress(node) }}</code>
</TableCell>
<TableCell class="py-4">
<span class="text-sm text-muted-foreground">{{ formatRegion(node.region) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<Badge
:variant="statusVariant(node.status)"
:title="statusTitle(node)"
class="font-medium px-2.5 py-0.5 text-xs"
>
{{ statusLabel(node) }}
</Badge>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span
class="text-sm tabular-nums"
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
>{{ formatFailureRate(node) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : nodeProxyVersion(node) }}</span>
</TableCell>
<TableCell class="py-4">
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<div class="flex items-center justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="testingNodes.has(node.id) ? '测试中...' : '测试连通性'"
:disabled="testingNodes.has(node.id)"
@click="handleTest(node)"
>
<Loader2
v-if="testingNodes.has(node.id)"
class="h-4 w-4 animate-spin"
<ChevronDown
v-if="isNodeExpanded(node.id)"
class="h-3.5 w-3.5"
/>
<Activity
<ChevronRight
v-else
class="h-4 w-4"
class="h-3.5 w-3.5"
/>
</Button>
<Button
v-if="node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑"
@click="handleEdit(node)"
</button>
</TableCell>
<TableCell class="py-4">
<div class="flex items-center gap-1.5">
<span class="text-sm font-semibold">{{ node.name }}</span>
<Badge
v-if="node.is_manual"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
手动
</Badge>
<Badge
v-if="node.tunnel_mode"
variant="outline"
class="text-[10px] px-1.5 py-0"
>
Tunnel
</Badge>
<Badge
v-if="nodeSchedulingBadge(node)"
:variant="nodeSchedulingBadge(node)!.variant"
class="text-[10px] px-1.5 py-0"
>
{{ nodeSchedulingBadge(node)!.label }}
</Badge>
<HardwareTooltip :node="node" />
</div>
</TableCell>
<TableCell class="py-4">
<code class="text-xs text-muted-foreground">{{ nodeAddress(node) }}</code>
</TableCell>
<TableCell class="py-4">
<span class="text-sm text-muted-foreground">{{ formatRegion(node.region) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<Badge
:variant="statusVariant(node.status)"
:title="statusTitle(node)"
class="font-medium px-2.5 py-0.5 text-xs"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
title="远程配置"
@click="handleConfig(node)"
>
<Settings class="h-4 w-4" />
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
title="连接事件"
@click="handleViewEvents(node)"
>
<History class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除"
@click="handleDelete(node)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
{{ statusLabel(node) }}
</Badge>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span
class="text-sm tabular-nums"
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
>{{ formatFailureRate(node) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : nodeProxyVersion(node) }}</span>
</TableCell>
<TableCell class="py-4">
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
</TableCell>
<TableCell class="py-4 text-center">
<div class="flex items-center justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="testingNodes.has(node.id) ? '测试中...' : '测试连通性'"
:disabled="testingNodes.has(node.id)"
@click="handleTest(node)"
>
<Loader2
v-if="testingNodes.has(node.id)"
class="h-4 w-4 animate-spin"
/>
<Activity
v-else
class="h-4 w-4"
/>
</Button>
<Button
v-if="node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑"
@click="handleEdit(node)"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
title="远程配置"
@click="handleConfig(node)"
>
<Settings class="h-4 w-4" />
</Button>
<Button
v-if="!node.is_manual"
variant="ghost"
size="icon"
class="h-8 w-8"
title="连接事件"
@click="handleViewEvents(node)"
>
<History class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除"
@click="handleDelete(node)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
<TableRow
v-if="isNodeExpanded(node.id)"
class="border-b border-border/40 hover:bg-transparent"
>
<TableCell
colspan="11"
class="p-0"
>
<ProxyNodeDataPanel
:node="node"
:state="nodeDetails[node.id]"
@refresh="loadNodeDetails(node)"
/>
</TableCell>
</TableRow>
</template>
<TableRow v-if="paginatedNodes.length === 0">
<TableCell
colspan="10"
colspan="11"
class="py-12 text-center text-muted-foreground text-sm"
>
{{ store.loading ? '加载中...' : '暂无代理节点' }}
@@ -327,6 +364,21 @@
<div class="flex items-start justify-between mb-2">
<div>
<div class="flex items-center gap-1.5">
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 shrink-0"
:title="isNodeExpanded(node.id) ? '收起数据' : '展开数据'"
@click="toggleNodeDetails(node)"
>
<ChevronDown
v-if="isNodeExpanded(node.id)"
class="h-3.5 w-3.5"
/>
<ChevronRight
v-else
class="h-3.5 w-3.5"
/>
</button>
<span class="font-semibold text-sm">{{ node.name }}</span>
<Badge
v-if="node.is_manual"
@@ -390,7 +442,7 @@
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
<div class="flex items-center gap-1">
<div class="flex flex-wrap items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
@@ -439,6 +491,16 @@
</Button>
</div>
</div>
<div
v-if="isNodeExpanded(node.id)"
class="mt-4 -mx-4 sm:-mx-5"
>
<ProxyNodeDataPanel
:node="node"
:state="nodeDetails[node.id]"
@refresh="loadNodeDetails(node)"
/>
</div>
</div>
<div
v-if="paginatedNodes.length === 0"
@@ -779,6 +841,7 @@ import {
proxyNodesApi,
type ProxyNode,
type ProxyNodeEvent,
type ProxyNodeMetricsResponse,
type ProxyNodeRemoteConfig,
type ProxyNodeSchedulingState,
type ProxyNodeTestResult,
@@ -808,10 +871,11 @@ import {
Dialog,
} from '@/components/ui'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History } from 'lucide-vue-next'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight } from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { formatRegion } from '@/utils/region'
import HardwareTooltip from './components/HardwareTooltip.vue'
import ProxyNodeDataPanel from './components/ProxyNodeDataPanel.vue'
const { success, error: toastError } = useToast()
const { confirmDanger } = useConfirm()
@@ -860,6 +924,18 @@ const eventsNode = ref<ProxyNode | null>(null)
const nodeEvents = ref<ProxyNodeEvent[]>([])
const loadingEvents = ref(false)
interface ProxyNodeDetailState {
loading: boolean
error: string | null
node: ProxyNode | null
metrics: ProxyNodeMetricsResponse | null
events: ProxyNodeEvent[]
loadedAt: number | null
}
const expandedNodeIds = ref(new Set<string>())
const nodeDetails = ref<Record<string, ProxyNodeDetailState>>({})
// 测试连通性
const testingNodes = ref(new Set<string>())
const testingUrl = ref(false)
@@ -1131,12 +1207,82 @@ async function handleTest(node: ProxyNode) {
}
}
function createNodeDetailState(): ProxyNodeDetailState {
return {
loading: false,
error: null,
node: null,
metrics: null,
events: [],
loadedAt: null,
}
}
function updateNodeDetailState(nodeId: string, patch: Partial<ProxyNodeDetailState>) {
nodeDetails.value = {
...nodeDetails.value,
[nodeId]: {
...(nodeDetails.value[nodeId] ?? createNodeDetailState()),
...patch,
},
}
}
function isNodeExpanded(nodeId: string) {
return expandedNodeIds.value.has(nodeId)
}
function toggleNodeDetails(node: ProxyNode) {
const next = new Set(expandedNodeIds.value)
if (next.has(node.id)) {
next.delete(node.id)
expandedNodeIds.value = next
return
}
next.add(node.id)
expandedNodeIds.value = next
const detailState = nodeDetails.value[node.id]
if (!detailState?.loadedAt && !detailState?.loading) {
void loadNodeDetails(node)
}
}
async function loadNodeDetails(node: ProxyNode) {
updateNodeDetailState(node.id, { loading: true, error: null })
const to = Math.floor(Date.now() / 1000)
const from = to - 24 * 60 * 60
const eventsFrom = to - 7 * 24 * 60 * 60
try {
const [detail, metrics, events] = await Promise.all([
proxyNodesApi.getNode(node.id),
proxyNodesApi.listNodeMetrics(node.id, { from, to, step: '1h' }),
proxyNodesApi.listNodeEvents(node.id, { limit: 8, from: eventsFrom, to }),
])
updateNodeDetailState(node.id, {
loading: false,
error: null,
node: detail.node,
metrics,
events: events.items,
loadedAt: Date.now(),
})
} catch (err: unknown) {
updateNodeDetailState(node.id, {
loading: false,
error: parseApiError(err, '加载节点数据失败'),
})
}
}
async function handleViewEvents(node: ProxyNode) {
eventsNode.value = node
showEventsDialog.value = true
loadingEvents.value = true
try {
const res = await proxyNodesApi.listNodeEvents(node.id, 50)
const res = await proxyNodesApi.listNodeEvents(node.id, { limit: 50 })
nodeEvents.value = res.items
} catch (err: unknown) {
toastError(parseApiError(err, '加载事件失败'))

View File

@@ -102,6 +102,9 @@
:audit-log-retention-days="systemConfig.audit_log_retention_days"
:request-candidates-retention-days="systemConfig.request_candidates_retention_days"
:request-candidates-cleanup-batch-size="systemConfig.request_candidates_cleanup_batch_size"
:proxy-node-metrics-1m-retention-days="systemConfig.proxy_node_metrics_1m_retention_days"
:proxy-node-metrics-1h-retention-days="systemConfig.proxy_node_metrics_1h_retention_days"
:proxy-node-metrics-cleanup-batch-size="systemConfig.proxy_node_metrics_cleanup_batch_size"
:loading="cleanupConfigLoading"
:has-changes="hasCleanupConfigChanges"
@save="saveCleanupConfig"
@@ -114,6 +117,9 @@
@update:audit-log-retention-days="systemConfig.audit_log_retention_days = $event"
@update:request-candidates-retention-days="systemConfig.request_candidates_retention_days = $event"
@update:request-candidates-cleanup-batch-size="systemConfig.request_candidates_cleanup_batch_size = $event"
@update:proxy-node-metrics-1m-retention-days="systemConfig.proxy_node_metrics_1m_retention_days = $event"
@update:proxy-node-metrics-1h-retention-days="systemConfig.proxy_node_metrics_1h_retention_days = $event"
@update:proxy-node-metrics-cleanup-batch-size="systemConfig.proxy_node_metrics_cleanup_batch_size = $event"
/>
<!-- 定时任务 -->

View File

@@ -0,0 +1,478 @@
<template>
<div class="px-4 sm:px-6 py-4 bg-muted/15 border-t border-border/40">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div>
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold">
节点数据
</h4>
<Badge
variant="outline"
class="text-[10px] px-1.5 py-0"
>
最近 24 小时
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-1">
{{ loadedText }}
</p>
</div>
<Button
variant="ghost"
size="sm"
class="h-8 px-2 text-xs self-start sm:self-auto"
:disabled="state?.loading"
@click="$emit('refresh')"
>
<RefreshCw
class="h-3.5 w-3.5 mr-1"
:class="state?.loading ? 'animate-spin' : ''"
/>
刷新
</Button>
</div>
<div
v-if="state?.loading && !state.metrics"
class="py-8 flex items-center justify-center gap-2 text-sm text-muted-foreground"
>
<Loader2 class="h-4 w-4 animate-spin" />
加载节点数据...
</div>
<div
v-else-if="state?.error"
class="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive flex items-start gap-2"
>
<AlertTriangle class="h-4 w-4 mt-0.5 shrink-0" />
<span>{{ state.error }}</span>
</div>
<div
v-else
class="space-y-4"
>
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2">
<div
v-for="item in summaryStats"
:key="item.label"
class="rounded-lg border border-border/50 bg-background/70 px-3 py-2 min-w-0"
>
<div class="text-[11px] text-muted-foreground truncate">
{{ item.label }}
</div>
<div
class="text-sm font-semibold tabular-nums truncate mt-0.5"
:class="item.tone"
>
{{ item.value }}
</div>
<div
v-if="item.hint"
class="text-[10px] text-muted-foreground truncate mt-0.5"
>
{{ item.hint }}
</div>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(280px,0.9fr)]">
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
<div class="flex items-center justify-between gap-3 mb-3">
<div>
<h5 class="text-xs font-semibold">
在线率采样
</h5>
<p class="text-[11px] text-muted-foreground mt-0.5">
1h 颜色随在线率和错误数变化
</p>
</div>
<span class="text-[11px] text-muted-foreground tabular-nums shrink-0">{{ formatNumber(bucketItems.length) }} </span>
</div>
<div
v-if="bucketItems.length > 0"
class="h-20 flex items-end gap-1 overflow-hidden"
>
<div
v-for="bucket in bucketItems"
:key="bucket.bucket_start_unix_secs"
class="flex-1 min-w-[4px] rounded-t-sm"
:class="bucketBarClass(bucket)"
:style="{ height: bucketBarHeight(bucket) }"
:title="bucketTitle(bucket)"
/>
</div>
<div
v-else
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
>
暂无采样数据
</div>
</section>
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
<div class="flex items-center justify-between gap-2 mb-3">
<h5 class="text-xs font-semibold">
最近事件
</h5>
<span class="text-[11px] text-muted-foreground tabular-nums">{{ recentEvents.length }}/8</span>
</div>
<div
v-if="recentEvents.length === 0"
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
>
暂无关键事件
</div>
<div
v-else
class="space-y-1.5 max-h-28 overflow-y-auto pr-1"
>
<div
v-for="event in recentEvents"
:key="event.id"
class="flex items-center gap-2 text-xs min-w-0"
>
<Badge
:variant="eventTypeVariant(event.event_type)"
class="text-[10px] px-1.5 py-0 shrink-0"
>
{{ eventTypeLabel(event.event_type) }}
</Badge>
<span class="text-muted-foreground truncate flex-1">{{ eventDetail(event) }}</span>
<span class="text-[10px] text-muted-foreground/70 tabular-nums shrink-0">{{ formatTime(event.created_at || null) }}</span>
</div>
</div>
</section>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
<h5 class="text-xs font-semibold mb-3">
实时快照
</h5>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
<div
v-for="item in snapshotItems"
:key="item.label"
class="min-w-0"
>
<div class="text-[11px] text-muted-foreground truncate">
{{ item.label }}
</div>
<div class="text-xs font-medium tabular-nums truncate mt-0.5">
{{ item.value }}
</div>
</div>
</div>
</section>
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
<h5 class="text-xs font-semibold mb-3">
隧道计数器
</h5>
<div
v-if="tunnelMetrics"
class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2"
>
<div
v-for="item in tunnelCounterItems"
:key="item.label"
class="min-w-0"
>
<div class="text-[11px] text-muted-foreground truncate">
{{ item.label }}
</div>
<div class="text-xs font-medium tabular-nums truncate mt-0.5">
{{ item.value }}
</div>
</div>
</div>
<div
v-else
class="rounded-md bg-muted/30 py-4 text-center text-xs text-muted-foreground"
>
暂无隧道计数器
</div>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-vue-next'
import { Badge, Button } from '@/components/ui'
import type {
ProxyNode,
ProxyNodeEvent,
ProxyNodeMetricsBucket,
ProxyNodeMetricsResponse,
} from '@/api/proxy-nodes'
interface ProxyNodeDetailState {
loading: boolean
error: string | null
node: ProxyNode | null
metrics: ProxyNodeMetricsResponse | null
events: ProxyNodeEvent[]
loadedAt: number | null
}
const props = defineProps<{
node: ProxyNode
state?: ProxyNodeDetailState | null
}>()
defineEmits<{
refresh: []
}>()
const detailNode = computed(() => props.state?.node ?? props.node)
const metrics = computed(() => props.state?.metrics ?? null)
const metricsSummary = computed(() => metrics.value?.summary ?? null)
const bucketItems = computed(() => metrics.value?.items.slice(-24) ?? [])
const recentEvents = computed(() => props.state?.events.slice(0, 8) ?? [])
const metadata = computed(() => asRecord(detailNode.value.proxy_metadata))
const tunnelMetrics = computed(() => asRecord(metadata.value?.tunnel_metrics))
const loadedText = computed(() => {
if (props.state?.loading) return '正在刷新采样数据'
if (!props.state?.loadedAt) return '展开后自动读取 24 小时指标和最近关键事件'
return `数据刷新于 ${formatLoadedAt(props.state.loadedAt)}`
})
const summaryStats = computed(() => {
const summary = metricsSummary.value
const totalBytes = (summary?.ws_in_bytes_delta ?? 0) + (summary?.ws_out_bytes_delta ?? 0)
return [
{
label: '在线率',
value: formatPercent(summary?.uptime_ratio ?? null),
hint: `${formatNumber(summary?.uptime_samples ?? 0)}/${formatNumber(summary?.samples ?? 0)} 样本`,
tone: uptimeTone(summary?.uptime_ratio ?? null),
},
{
label: '心跳 RTT',
value: formatMs(summary?.heartbeat_rtt_ms_avg ?? null),
hint: `峰值 ${formatMs(summary?.heartbeat_rtt_ms_max ?? null)}`,
tone: '',
},
{
label: '并发峰值',
value: formatNumber(summary?.active_connections_max ?? 0),
hint: `均值 ${formatDecimal(summary?.active_connections_avg ?? null)}`,
tone: '',
},
{
label: '断开',
value: formatNumber(summary?.disconnects_delta ?? 0),
hint: '24h delta',
tone: (summary?.disconnects_delta ?? 0) > 0 ? 'text-yellow-600 dark:text-yellow-400' : '',
},
{
label: '连接错误',
value: formatNumber(summary?.connect_errors_delta ?? 0),
hint: '24h delta',
tone: (summary?.connect_errors_delta ?? 0) > 0 ? 'text-destructive' : '',
},
{
label: 'WS 流量',
value: formatBytes(totalBytes),
hint: `${formatNumber((summary?.ws_in_frames_delta ?? 0) + (summary?.ws_out_frames_delta ?? 0))}`,
tone: '',
},
]
})
const snapshotItems = computed(() => {
const node = detailNode.value
return [
{ label: '当前并发', value: formatNumber(node.active_connections ?? 0) },
{ label: '心跳间隔', value: `${node.heartbeat_interval ?? '-'}s` },
{ label: '最后心跳', value: formatTime(node.last_heartbeat_at) },
{ label: '隧道连接', value: node.tunnel_connected ? '已连接' : '未连接' },
{ label: '连接时间', value: formatTime(node.tunnel_connected_at) },
{ label: '容量估算', value: node.estimated_max_concurrency == null ? '-' : formatNumber(node.estimated_max_concurrency) },
{ label: '配置版本', value: `v${node.config_version}` },
{ label: '注册来源', value: node.registered_by || '-' },
{ label: '代理版本', value: stringField(metadata.value, 'version') || '-' },
]
})
const tunnelCounterItems = computed(() => [
{ label: '建连尝试', value: formatTunnelNumber('connect_attempts') },
{ label: '建连成功', value: formatTunnelNumber('connect_successes') },
{ label: '建连错误', value: formatTunnelNumber('connect_errors') },
{ label: '断开次数', value: formatTunnelNumber('disconnects') },
{ label: '最近连上', value: formatUnixSecs(numberField(tunnelMetrics.value, 'last_connected_at_unix_secs')) },
{ label: '最近断开', value: formatUnixSecs(numberField(tunnelMetrics.value, 'last_disconnected_at_unix_secs')) },
{ label: '最近在线', value: formatDurationMs(numberField(tunnelMetrics.value, 'last_connected_duration_ms')) },
{ label: '累计在线', value: formatDurationMs(numberField(tunnelMetrics.value, 'connected_duration_total_ms')) },
{ label: '心跳发送', value: formatTunnelNumber('heartbeat_sent') },
{ label: '心跳确认', value: formatTunnelNumber('heartbeat_ack') },
{ label: '最近 RTT', value: formatMs(numberField(tunnelMetrics.value, 'heartbeat_rtt_last_ms')) },
{ label: '平均 RTT', value: formatMs(numberField(tunnelMetrics.value, 'heartbeat_rtt_avg_ms')) },
{ label: 'WS 入站', value: formatBytes(numberField(tunnelMetrics.value, 'ws_in_bytes') ?? 0) },
{ label: 'WS 出站', value: formatBytes(numberField(tunnelMetrics.value, 'ws_out_bytes') ?? 0) },
{ label: '入站帧', value: formatTunnelNumber('ws_in_frames') },
{ label: '出站帧', value: formatTunnelNumber('ws_out_frames') },
])
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function numberField(record: Record<string, unknown> | null, key: string): number | null {
const value = record?.[key]
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return null
}
function stringField(record: Record<string, unknown> | null, key: string): string | null {
const value = record?.[key]
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed || null
}
function formatTunnelNumber(key: string) {
return formatNumber(numberField(tunnelMetrics.value, key) ?? 0)
}
function formatNumber(value: number) {
if (!Number.isFinite(value)) return '-'
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}K`
return String(Math.round(value))
}
function formatDecimal(value: number | null) {
if (value == null || !Number.isFinite(value)) return '-'
if (value === 0) return '0'
if (value < 10) return value.toFixed(1)
return formatNumber(value)
}
function formatPercent(value: number | null) {
if (value == null || !Number.isFinite(value)) return '-'
return `${(value * 100).toFixed(value >= 0.995 || value === 0 ? 0 : 1)}%`
}
function formatMs(value: number | null) {
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
if (value >= 1000) return `${(value / 1000).toFixed(1)}s`
return `${Math.round(value)}ms`
}
function formatDurationMs(value: number | null) {
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
const totalSeconds = Math.floor(value / 1000)
if (totalSeconds < 60) return `${totalSeconds}s`
const totalMinutes = Math.floor(totalSeconds / 60)
if (totalMinutes < 60) return `${totalMinutes}m`
const totalHours = Math.floor(totalMinutes / 60)
if (totalHours < 24) return `${totalHours}h ${totalMinutes % 60}m`
const days = Math.floor(totalHours / 24)
return `${days}d ${totalHours % 24}h`
}
function formatUnixSecs(value: number | null) {
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
return formatTime(new Date(value * 1000).toISOString())
}
function formatBytes(value: number) {
if (!Number.isFinite(value) || value <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let unit = 0
while (size >= 1024 && unit < units.length - 1) {
size /= 1024
unit += 1
}
return `${unit === 0 ? Math.round(size) : size.toFixed(1)} ${units[unit]}`
}
function formatTime(iso: string | null) {
if (!iso) return '-'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '-'
const diff = (Date.now() - date.getTime()) / 1000
if (diff >= 0 && diff < 60) return '刚刚'
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)}分钟前`
if (diff >= 0 && diff < 86400) return `${Math.floor(diff / 3600)}小时前`
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function formatLoadedAt(value: number) {
return new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function bucketBarHeight(bucket: ProxyNodeMetricsBucket) {
if (bucket.samples <= 0 || bucket.uptime_ratio == null) return '10%'
return `${Math.max(10, Math.round(bucket.uptime_ratio * 100))}%`
}
function bucketBarClass(bucket: ProxyNodeMetricsBucket) {
if (bucket.samples <= 0) return 'bg-muted'
if (bucket.error_events_delta > 0 || bucket.connect_errors_delta > 0) return 'bg-destructive/80'
if ((bucket.uptime_ratio ?? 0) < 0.98 || bucket.disconnects_delta > 0) return 'bg-yellow-500/80'
return 'bg-primary/80'
}
function bucketTitle(bucket: ProxyNodeMetricsBucket) {
return [
formatBucketTime(bucket.bucket_start),
`在线率 ${formatPercent(bucket.uptime_ratio)}`,
`RTT ${formatMs(bucket.heartbeat_rtt_ms_avg)}`,
`断开 ${formatNumber(bucket.disconnects_delta)}`,
`错误 ${formatNumber(bucket.connect_errors_delta + bucket.error_events_delta)}`,
].join('')
}
function formatBucketTime(value: string | null) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit' })
}
function uptimeTone(value: number | null) {
if (value == null) return ''
if (value < 0.95) return 'text-destructive'
if (value < 0.99) return 'text-yellow-600 dark:text-yellow-400'
return 'text-primary'
}
function eventTypeLabel(type: string) {
switch (type) {
case 'connected': return '连接'
case 'disconnected': return '断开'
case 'error': return '错误'
case 'tunnel_err': return '隧道错误'
default: return type
}
}
function eventTypeVariant(type: string) {
switch (type) {
case 'connected': return 'success' as const
case 'disconnected':
case 'error':
case 'tunnel_err':
return 'destructive' as const
default: return 'secondary' as const
}
}
function eventDetail(event: ProxyNodeEvent) {
const metadata = asRecord(event.event_metadata)
return event.detail || stringField(metadata, 'message') || stringField(metadata, 'category') || '-'
}
</script>

View File

@@ -192,6 +192,72 @@
独立控制候选记录大表清理节奏不再跟随 Key 删除联动
</p>
</div>
<div>
<Label
for="proxy-node-metrics-1m-retention-days"
class="block text-sm font-medium"
>
代理 1m 指标保留天数
</Label>
<Input
id="proxy-node-metrics-1m-retention-days"
:model-value="proxyNodeMetrics1mRetentionDays"
type="number"
min="1"
max="365"
placeholder="30"
class="mt-1"
@update:model-value="$emit('update:proxyNodeMetrics1mRetentionDays', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
用于代理节点稳定性分钟级图表后端最少保留 1
</p>
</div>
<div>
<Label
for="proxy-node-metrics-1h-retention-days"
class="block text-sm font-medium"
>
代理 1h 指标保留天数
</Label>
<Input
id="proxy-node-metrics-1h-retention-days"
:model-value="proxyNodeMetrics1hRetentionDays"
type="number"
min="1"
max="1095"
placeholder="180"
class="mt-1"
@update:model-value="$emit('update:proxyNodeMetrics1hRetentionDays', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
小时级聚合用于长期趋势不能短于 1m 指标保留天数
</p>
</div>
<div>
<Label
for="proxy-node-metrics-cleanup-batch-size"
class="block text-sm font-medium"
>
代理指标清理批次
</Label>
<Input
id="proxy-node-metrics-cleanup-batch-size"
:model-value="proxyNodeMetricsCleanupBatchSize"
type="number"
min="1"
max="50000"
placeholder="5000"
class="mt-1"
@update:model-value="$emit('update:proxyNodeMetricsCleanupBatchSize', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
独立限制 proxy_node_metrics 表的单批删除数量
</p>
</div>
</div>
<!-- 清理策略说明 -->
@@ -206,6 +272,7 @@
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
<p>5. <strong>候选记录</strong>: 独立按保留天数清理 request_candidates 审计记录不再跟随 Key 删除联动</p>
<p>6. <strong>审计日志</strong>: 独立清理记录用户登录操作等安全事件</p>
<p>7. <strong>代理指标</strong>: 仅保留 1m/1h 聚合桶清理任务按批次删除过期桶</p>
</div>
</div>
@@ -322,6 +389,9 @@ defineProps<{
auditLogRetentionDays: number
requestCandidatesRetentionDays: number
requestCandidatesCleanupBatchSize: number
proxyNodeMetrics1mRetentionDays: number
proxyNodeMetrics1hRetentionDays: number
proxyNodeMetricsCleanupBatchSize: number
loading: boolean
hasChanges: boolean
}>()
@@ -337,6 +407,9 @@ defineEmits<{
'update:auditLogRetentionDays': [value: number]
'update:requestCandidatesRetentionDays': [value: number]
'update:requestCandidatesCleanupBatchSize': [value: number]
'update:proxyNodeMetrics1mRetentionDays': [value: number]
'update:proxyNodeMetrics1hRetentionDays': [value: number]
'update:proxyNodeMetricsCleanupBatchSize': [value: number]
}>()
const cleanupRuns = ref<CleanupRunRecord[]>([])

View File

@@ -34,6 +34,9 @@ export interface SystemConfig {
audit_log_retention_days: number
request_candidates_retention_days: number
request_candidates_cleanup_batch_size: number
proxy_node_metrics_1m_retention_days: number
proxy_node_metrics_1h_retention_days: number
proxy_node_metrics_cleanup_batch_size: number
// 定时任务
enable_provider_checkin: boolean
provider_checkin_time: string
@@ -70,6 +73,9 @@ const CONFIG_KEYS = [
'audit_log_retention_days',
'request_candidates_retention_days',
'request_candidates_cleanup_batch_size',
'proxy_node_metrics_1m_retention_days',
'proxy_node_metrics_1h_retention_days',
'proxy_node_metrics_cleanup_batch_size',
// 定时任务
'enable_provider_checkin',
'provider_checkin_time',
@@ -107,6 +113,9 @@ function createDefaultConfig(): SystemConfig {
audit_log_retention_days: 30,
request_candidates_retention_days: 30,
request_candidates_cleanup_batch_size: 5000,
proxy_node_metrics_1m_retention_days: 30,
proxy_node_metrics_1h_retention_days: 180,
proxy_node_metrics_cleanup_batch_size: 5000,
// 定时任务
enable_provider_checkin: true,
provider_checkin_time: '01:05',
@@ -181,7 +190,13 @@ export function useSystemConfig() {
systemConfig.value.request_candidates_retention_days !==
originalConfig.value.request_candidates_retention_days ||
systemConfig.value.request_candidates_cleanup_batch_size !==
originalConfig.value.request_candidates_cleanup_batch_size
originalConfig.value.request_candidates_cleanup_batch_size ||
systemConfig.value.proxy_node_metrics_1m_retention_days !==
originalConfig.value.proxy_node_metrics_1m_retention_days ||
systemConfig.value.proxy_node_metrics_1h_retention_days !==
originalConfig.value.proxy_node_metrics_1h_retention_days ||
systemConfig.value.proxy_node_metrics_cleanup_batch_size !==
originalConfig.value.proxy_node_metrics_cleanup_batch_size
)
})
@@ -441,6 +456,21 @@ export function useSystemConfig() {
value: systemConfig.value.request_candidates_cleanup_batch_size,
description: '请求候选记录每批次清理条数',
},
{
key: 'proxy_node_metrics_1m_retention_days',
value: systemConfig.value.proxy_node_metrics_1m_retention_days,
description: '代理节点 1m 指标保留天数',
},
{
key: 'proxy_node_metrics_1h_retention_days',
value: systemConfig.value.proxy_node_metrics_1h_retention_days,
description: '代理节点 1h 指标保留天数',
},
{
key: 'proxy_node_metrics_cleanup_batch_size',
value: systemConfig.value.proxy_node_metrics_cleanup_batch_size,
description: '代理节点指标每批次清理条数',
},
]
await Promise.all(
@@ -462,6 +492,12 @@ export function useSystemConfig() {
systemConfig.value.request_candidates_retention_days
originalConfig.value.request_candidates_cleanup_batch_size =
systemConfig.value.request_candidates_cleanup_batch_size
originalConfig.value.proxy_node_metrics_1m_retention_days =
systemConfig.value.proxy_node_metrics_1m_retention_days
originalConfig.value.proxy_node_metrics_1h_retention_days =
systemConfig.value.proxy_node_metrics_1h_retention_days
originalConfig.value.proxy_node_metrics_cleanup_batch_size =
systemConfig.value.proxy_node_metrics_cleanup_batch_size
}
success('请求记录清理配置已保存')
} catch (err) {

View File

@@ -191,6 +191,15 @@
<!-- 操作按钮 -->
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="一键安装并配置 CLI"
@click="openInstallDialog(apiKey)"
>
<Terminal class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -273,6 +282,15 @@
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="一键安装并配置 CLI"
@click="openInstallDialog(apiKey)"
>
<Terminal class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -503,13 +521,110 @@
<template #footer>
<Button
class="h-10 px-5"
@click="showKeyDialog = false"
@click="closeCreatedKeyDialog"
>
确定
</Button>
</template>
</Dialog>
<!-- 一键安装并配置 CLI 对话框 -->
<Dialog
v-model="showInstallDialog"
size="lg"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Terminal class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
一键安装并配置 CLI
</h3>
<p class="text-xs text-muted-foreground truncate">
当前密钥:{{ selectedInstallApiKey?.name || '未选择' }}
</p>
</div>
</div>
</div>
</template>
<div class="space-y-5">
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
选择要配置的 CLI 和目标系统Aether 会生成 15 分钟内有效的一次性 install code。页面命令不会包含原始 API Key。
</div>
<div class="space-y-2">
<Label class="text-sm font-semibold">目标 CLI</Label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<Button
v-for="option in installCliOptions"
:key="option.value"
:variant="installCli === option.value ? 'default' : 'outline'"
class="justify-start h-auto py-3"
@click="selectInstallCli(option.value)"
>
{{ option.label }}
</Button>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm font-semibold">目标系统</Label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<Button
v-for="option in installSystemOptions"
:key="option.value"
:variant="installSystem === option.value ? 'default' : 'outline'"
class="justify-start h-auto py-3"
@click="selectInstallSystem(option.value)"
>
{{ option.label }}
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
<Button
variant="ghost"
size="sm"
:disabled="installLoading || !selectedInstallApiKey"
@click="refreshInstallCommand"
>
{{ installLoading ? '生成中...' : '重新生成' }}
</Button>
</div>
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
</div>
<p class="text-xs text-muted-foreground">
{{ installCommandHint }}
</p>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-10 px-5"
@click="showInstallDialog = false"
>
关闭
</Button>
<Button
class="h-10 px-5 shadow-lg shadow-primary/20"
:disabled="!installCommand || installLoading"
@click="copyTextToClipboard(installCommand)"
>
复制命令
</Button>
</template>
</Dialog>
<!-- 删除确认对话框 -->
<AlertDialog
v-model="showDeleteDialog"
@@ -525,8 +640,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { meApi, type ApiKey } from '@/api/me'
import { ref, onMounted, computed, watch } from 'vue'
import { meApi, type ApiKey, type InstallTargetCli, type InstallTargetSystem, type ApiKeyInstallSession } from '@/api/me'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -543,17 +658,28 @@ import {
TableRow
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen } from 'lucide-vue-next'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen, Terminal } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
import { formatRateLimitSimple } from '@/utils/format'
import { parseNumberInput } from '@/utils/form'
import { getErrorStatus } from '@/types/api-error'
import { computed } from 'vue'
const { success, error: showError } = useToast()
const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
{ value: 'claude_code', label: 'Claude Code' },
{ value: 'codex_cli', label: 'Codex CLI' },
{ value: 'gemini_cli', label: 'Gemini CLI' }
]
const installSystemOptions: Array<{ value: Exclude<InstallTargetSystem, 'auto'>; label: string }> = [
{ value: 'macos', label: 'macOS' },
{ value: 'linux', label: 'Linux' },
{ value: 'windows', label: 'Windows' }
]
const apiKeys = ref<ApiKey[]>([])
const loading = ref(false)
const creating = ref(false)
@@ -571,6 +697,7 @@ const paginatedApiKeys = computed(() => {
const showCreateDialog = ref(false)
const showKeyDialog = ref(false)
const showDeleteDialog = ref(false)
const showInstallDialog = ref(false)
const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined)
@@ -578,11 +705,38 @@ const newKeyConcurrentLimit = ref<number | undefined>(undefined)
const newKeyValue = ref('')
const keyToDelete = ref<ApiKey | null>(null)
const editingApiKey = ref<ApiKey | null>(null)
const selectedInstallApiKey = ref<ApiKey | null>(null)
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
const installCli = ref<InstallTargetCli>('claude_code')
const installSystem = ref<Exclude<InstallTargetSystem, 'auto'>>('linux')
const installSession = ref<ApiKeyInstallSession | null>(null)
const installLoading = ref(false)
const installCommand = computed(() => {
if (!installSession.value) return ''
return installSystem.value === 'windows'
? installSession.value.powershell_command
: installSession.value.unix_command
})
const installCommandHint = computed(() => {
if (installSystem.value === 'windows') {
return 'Windows 请在 PowerShell 中执行。install code 使用后立即失效,如需再次执行请重新生成。'
}
return 'macOS / Linux 请在 sh 兼容终端中执行。install code 使用后立即失效,如需再次执行请重新生成。'
})
onMounted(() => {
installSystem.value = detectCurrentSystem()
loadApiKeys()
})
watch(showKeyDialog, (isOpen) => {
if (!isOpen && pendingFirstInstallApiKey.value) {
closeCreatedKeyDialog()
}
})
async function loadApiKeys() {
loading.value = true
try {
@@ -618,6 +772,57 @@ function openCreateApiKeyDialog() {
showCreateDialog.value = true
}
function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
const platform = window.navigator.platform.toLowerCase()
const userAgent = window.navigator.userAgent.toLowerCase()
if (platform.includes('mac')) return 'macos'
if (platform.includes('win') || userAgent.includes('windows')) return 'windows'
return 'linux'
}
async function openInstallDialog(apiKey: ApiKey) {
selectedInstallApiKey.value = apiKey
installSession.value = null
showInstallDialog.value = true
await refreshInstallCommand()
}
async function selectInstallCli(value: InstallTargetCli) {
installCli.value = value
await refreshInstallCommand()
}
async function selectInstallSystem(value: Exclude<InstallTargetSystem, 'auto'>) {
installSystem.value = value
await refreshInstallCommand()
}
async function refreshInstallCommand() {
if (!selectedInstallApiKey.value) return
installLoading.value = true
installSession.value = null
try {
installSession.value = await meApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
target_cli: installCli.value,
target_system: installSystem.value,
})
} catch (error) {
log.error('生成 CLI 安装命令失败:', error)
showError(parseApiError(error, '生成 CLI 安装命令失败'))
} finally {
installLoading.value = false
}
}
function closeCreatedKeyDialog() {
showKeyDialog.value = false
const pending = pendingFirstInstallApiKey.value
pendingFirstInstallApiKey.value = null
if (pending) {
void openInstallDialog(pending)
}
}
function closeApiKeyDialog() {
showCreateDialog.value = false
editingApiKey.value = null
@@ -634,6 +839,7 @@ async function saveApiKey() {
creating.value = true
try {
const isCreatingFirstApiKey = !editingApiKey.value && apiKeys.value.length === 0
if (editingApiKey.value) {
await meApi.updateApiKey(editingApiKey.value.id, {
name: newKeyName.value,
@@ -648,6 +854,9 @@ async function saveApiKey() {
concurrent_limit: newKeyConcurrentLimit.value,
})
newKeyValue.value = newKey.key || ''
if (isCreatingFirstApiKey) {
pendingFirstInstallApiKey.value = newKey
}
showKeyDialog.value = true
success('API 密钥创建成功')
}