1043 lines
39 KiB
Vue
1043 lines
39 KiB
Vue
<template>
|
||
<PageCard title="检测控制" description="已升级为任务中心:支持启停控制、阶段查看、日志常驻和会话回看,并沉淀线程/代理/进度快照。">
|
||
<div class="actions">
|
||
<el-button type="primary" :loading="actionLoading === 'start'" @click="invoke('start')">启动检测</el-button>
|
||
<el-button :loading="actionLoading === 'stop'" @click="invoke('stop')">停止检测</el-button>
|
||
<el-button plain :loading="loading" @click="loadStatus">刷新状态</el-button>
|
||
<el-button plain @click="goRuntime">前往运行中心</el-button>
|
||
<el-button-group>
|
||
<el-button plain :type="status.worker_log_sync_enabled ? '' : 'primary'" @click="updateWorkerLogSync(false, 'key')">关闭回传</el-button>
|
||
<el-button plain :type="status.worker_log_sync_enabled && status.worker_log_sync_mode === 'key' ? 'primary' : ''" @click="updateWorkerLogSync(true, 'key')">关键回传</el-button>
|
||
<el-button plain :type="status.worker_log_sync_enabled && status.worker_log_sync_mode === 'full' ? 'primary' : ''" @click="updateWorkerLogSync(true, 'full')">全量回传</el-button>
|
||
</el-button-group>
|
||
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
|
||
<span class="updated-at">最近同步:{{ lastUpdatedAt || "暂无" }}</span>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="lastAction.message"
|
||
:title="`${lastAction.label}:${lastAction.message}${lastAction.at ? `(${lastAction.at})` : ''}`"
|
||
:type="lastAction.type"
|
||
:closable="false"
|
||
show-icon
|
||
class="action-alert"
|
||
/>
|
||
|
||
<el-descriptions :column="2" border>
|
||
<el-descriptions-item label="本机 Worker 在线">{{ status.worker_online ? "是" : "否" }}</el-descriptions-item>
|
||
<el-descriptions-item label="运行模式">{{ status.worker_mode || "windows-local" }}</el-descriptions-item>
|
||
<el-descriptions-item label="Worker 服务">{{ status.worker_service_name || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="API 服务">{{ status.api_service_name || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="检测进程数">{{ status.worker_process_count }}</el-descriptions-item>
|
||
<el-descriptions-item label="本机线程配置">{{ status.thread_count }}</el-descriptions-item>
|
||
<el-descriptions-item label="线程来源">{{ threadCountSummary }}</el-descriptions-item>
|
||
<el-descriptions-item v-if="isAggregateDetectView" label="当前汇总线程">{{ threadSnapshotActive }} / {{ threadSnapshotMax }}</el-descriptions-item>
|
||
<el-descriptions-item v-if="isAggregateDetectView" label="当前参与节点">{{ participatingNodeCount }}</el-descriptions-item>
|
||
<el-descriptions-item label="代理启用">{{ status.proxy_enable ? "是" : "否" }}</el-descriptions-item>
|
||
<el-descriptions-item label="允许直连">{{ status.allow_direct ? "是" : "否" }}</el-descriptions-item>
|
||
<el-descriptions-item label="代理池数量">{{ status.proxy_pool_count }}</el-descriptions-item>
|
||
<el-descriptions-item label="可用代理数">{{ status.available_proxy_count }}</el-descriptions-item>
|
||
<el-descriptions-item label="代理状态">{{ status.proxy_runtime_label || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="代理诊断">{{ proxyDiagnosisText }}</el-descriptions-item>
|
||
<el-descriptions-item label="最近代理刷新">{{ status.proxy_last_refresh_time || "暂无" }}</el-descriptions-item>
|
||
<el-descriptions-item label="最近 Worker 日志时间">{{ status.last_worker_log_time || "暂无" }}</el-descriptions-item>
|
||
<el-descriptions-item label="最近启动时间">{{ status.worker_latest_start_time || "暂无" }}</el-descriptions-item>
|
||
<el-descriptions-item label="远端日志回传">{{ status.worker_log_sync_enabled ? "已开启" : "已关闭" }}</el-descriptions-item>
|
||
<el-descriptions-item label="回传级别">{{ status.worker_log_sync_enabled ? (status.worker_log_sync_mode === "full" ? "全量" : "关键") : "-" }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<el-alert
|
||
v-if="status.proxy_runtime_detail"
|
||
:type="proxyAlertType"
|
||
:closable="false"
|
||
show-icon
|
||
:title="status.proxy_runtime_detail"
|
||
style="margin-top: 16px"
|
||
/>
|
||
<el-alert
|
||
v-if="status.proxy_supplier_empty"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
title="代理供应侧最近返回 200,但原始代理数为 0;当前判断是供应池为空,不是程序拉取失败。"
|
||
style="margin-top: 12px"
|
||
/>
|
||
<el-alert
|
||
v-if="status.recent_warning"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
:title="status.recent_warning"
|
||
style="margin-top: 16px"
|
||
/>
|
||
<el-alert
|
||
v-for="alert in status.dependency_alerts || []"
|
||
:key="`${alert.kind}-${alert.detail}`"
|
||
:type="alert.level === 'error' ? 'error' : 'warning'"
|
||
:closable="false"
|
||
show-icon
|
||
:title="`${alert.title}:${alert.detail}`"
|
||
style="margin-top: 12px"
|
||
/>
|
||
<el-alert
|
||
v-if="status.worker_runtime_message"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
:title="status.worker_runtime_message"
|
||
style="margin-top: 12px"
|
||
/>
|
||
<el-alert
|
||
v-if="status.recent_event"
|
||
type="success"
|
||
:closable="false"
|
||
show-icon
|
||
:title="status.recent_event"
|
||
style="margin-top: 12px"
|
||
/>
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
type="info"
|
||
:title="detectScopeSummaryText"
|
||
style="margin-top: 12px"
|
||
/>
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="status.worker_log_sync_enabled ? 'info' : 'warning'"
|
||
:title="remoteLogMirrorText"
|
||
style="margin-top: 12px"
|
||
/>
|
||
|
||
<el-row :gutter="12" style="margin-top: 16px; margin-bottom: 16px">
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="待检测" :value="status.progress.pending || 0" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测中" :value="displayRunningStat" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测通过" :value="status.progress.completed || 0" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="黑名单" :value="status.progress.blacklisted || 0" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测失败" :value="status.progress.failed || 0" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="可注册" :value="registerableStat" /></el-col>
|
||
</el-row>
|
||
|
||
<div class="summary-grid">
|
||
<div class="summary-card">
|
||
<div class="summary-title">当前任务</div>
|
||
<div class="summary-value task-code">{{ activeJob?.job_code || "-" }}</div>
|
||
<div class="summary-note">{{ activeJobSummaryText }}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">检测进度</div>
|
||
<div class="summary-value">{{ status.progress_percent || 0 }}%</div>
|
||
<el-progress :percentage="Number(status.progress_percent || 0)" :stroke-width="10" />
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">当前阶段</div>
|
||
<div class="summary-value phase-value">{{ currentPhaseLabel }}</div>
|
||
<div class="summary-note">{{ currentPhaseDetail }}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">活跃线程</div>
|
||
<div class="summary-value">{{ threadSnapshotActive }} / {{ threadSnapshotMax }}</div>
|
||
<div class="summary-note">当前参与节点实际活跃线程 / 配置线程上限</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">当前速度</div>
|
||
<div class="summary-value">{{ queueProcessedPerMinute }}</div>
|
||
<div class="summary-note">约 {{ queueProcessedPerHour }} 项/小时</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">预计剩余</div>
|
||
<div class="summary-value">{{ estimatedRemainingText }}</div>
|
||
<div class="summary-note">按当前吞吐推算剩余 {{ remainingItemsEstimate }} 项</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">代理快照</div>
|
||
<div class="summary-value">{{ status.available_proxy_count || 0 }} / {{ status.proxy_pool_count || 0 }}</div>
|
||
<div class="summary-note">{{ proxyDiagnosisText }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-row v-if="activeJob" :gutter="12" style="margin-bottom: 16px">
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务待领" :value="activeJob.items_pending || 0" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务已领" :value="activeJobDisplayClaimed" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务执行中" :value="activeJobDisplayRunning" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="活跃线程" :value="threadSnapshotActive" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务完成" :value="activeJob.items_completed || 0" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务黑名单" :value="activeJob.items_blacklisted || 0" /></el-col>
|
||
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务失败" :value="activeJob.items_failed || 0" /></el-col>
|
||
</el-row>
|
||
|
||
<div v-if="activeJob" class="summary-grid summary-grid-tight">
|
||
<div class="summary-card">
|
||
<div class="summary-title">任务进度</div>
|
||
<div class="summary-value">{{ activeJob.progress_percent || 0 }}%</div>
|
||
<el-progress :percentage="Number(activeJob.progress_percent || 0)" :stroke-width="10" />
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">最近任务事件</div>
|
||
<div class="summary-value event-value">{{ activeJob.latest_event?.event_type || "-" }}</div>
|
||
<div class="summary-note">{{ activeJob.latest_event?.message || "当前还没有任务事件" }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="history-panel">
|
||
<div class="history-header">
|
||
<h3>队列健康度</h3>
|
||
<span class="history-note">聚合近 {{ queueSummary.window_minutes || 15 }} 分钟吞吐、租约和积压,便于后续多机扩容判断</span>
|
||
</div>
|
||
<div class="summary-grid summary-grid-tight">
|
||
<div class="summary-card">
|
||
<div class="summary-title">近窗吞吐</div>
|
||
<div class="summary-value">{{ queueSummary.throughput?.processed_recent || 0 }}</div>
|
||
<div class="summary-note">近 {{ queueSummary.window_minutes || 15 }} 分钟处理 {{ queueSummary.throughput?.processed_per_minute || 0 }} 项/分钟</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">租约风险</div>
|
||
<div class="summary-value">{{ queueSummary.queue?.overdue_leases || 0 }}</div>
|
||
<div class="summary-note">过期 {{ queueSummary.queue?.overdue_leases || 0 }} / 5 分钟内到期 {{ queueSummary.queue?.expiring_soon_leases || 0 }}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">最老待领</div>
|
||
<div class="summary-value">{{ queueSummary.queue?.oldest_pending_age_minutes || 0 }} 分钟</div>
|
||
<div class="summary-note">{{ queueSummary.queue?.oldest_pending_at || "当前没有待领取任务项" }}</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-title">任务收敛</div>
|
||
<div class="summary-value">{{ queueSummary.queue?.terminal_percent || 0 }}%</div>
|
||
<div class="summary-note">已收敛 {{ queueSummary.queue?.terminal || 0 }} / {{ queueSummary.queue?.items_total || 0 }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="queueAlertType"
|
||
style="margin-bottom: 12px"
|
||
:title="queueAlertText"
|
||
/>
|
||
|
||
<el-table :data="queueSummary.nodes || []" border style="margin-bottom: 16px" empty-text="当前没有可展示的节点吞吐摘要">
|
||
<el-table-column prop="node_code" label="节点" min-width="180" />
|
||
<el-table-column prop="items_running" label="任务执行中" min-width="100" />
|
||
<el-table-column prop="items_claimed" label="已领未跑" min-width="100" />
|
||
<el-table-column prop="processed_recent" label="近窗处理" min-width="100" />
|
||
<el-table-column prop="processed_per_minute" label="项/分钟" min-width="100" />
|
||
<el-table-column prop="completed_recent" label="完成" min-width="90" />
|
||
<el-table-column prop="blacklisted_recent" label="黑名单" min-width="90" />
|
||
<el-table-column prop="failed_recent" label="失败" min-width="90" />
|
||
</el-table>
|
||
</div>
|
||
|
||
<el-table v-if="activeJobDisplayNodeStats.length" :data="activeJobDisplayNodeStats" border style="margin-bottom: 16px">
|
||
<el-table-column prop="node_code" label="节点" min-width="180" />
|
||
<el-table-column prop="items_total" label="任务总数" min-width="100" />
|
||
<el-table-column prop="items_pending" label="待领" min-width="90" />
|
||
<el-table-column prop="items_claimed" label="已领" min-width="90" />
|
||
<el-table-column prop="items_running" label="任务执行中" min-width="100" />
|
||
<el-table-column label="活跃线程" min-width="130">
|
||
<template #default="{ row }">
|
||
<span>{{ row.active_threads || 0 }} / {{ row.max_threads || 0 }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="current_load" label="在途负载" min-width="100" />
|
||
<el-table-column prop="items_completed" label="完成" min-width="90" />
|
||
<el-table-column prop="items_failed" label="失败" min-width="90" />
|
||
</el-table>
|
||
|
||
<el-table :data="runs" border highlight-current-row @current-change="handleCurrentRunChange">
|
||
<el-table-column label="状态" min-width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="statusTagType(row.status)" effect="dark">{{ statusText(row.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="阶段" min-width="110">
|
||
<template #default="{ row }">
|
||
<el-tag :type="phaseTagType(row.phase_label, row.status)" plain>{{ row.phase_label || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="message" label="会话信息" min-width="280" show-overflow-tooltip />
|
||
<el-table-column prop="started_at" label="开始时间" min-width="160" />
|
||
<el-table-column prop="completed_at" label="结束时间" min-width="160" />
|
||
<el-table-column label="线程/代理" min-width="160">
|
||
<template #default="{ row }">
|
||
<span>线程 {{ row.settings_summary?.thread_count || 0 }} / 代理 {{ row.settings_summary?.proxy_pool_count || 0 }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="进度快照" min-width="220">
|
||
<template #default="{ row }">
|
||
<span>
|
||
待 {{ row.progress?.pending || 0 }}
|
||
/ 中 {{ row.progress?.running || 0 }}
|
||
/ 完 {{ row.progress?.completed || 0 }}
|
||
/ 黑 {{ row.progress?.blacklisted || 0 }}
|
||
/ 失败 {{ row.progress?.failed || 0 }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="task-detail">
|
||
<div class="task-detail-header">
|
||
<div>
|
||
<div class="task-detail-title">任务日志控制台</div>
|
||
<div class="task-detail-subtitle">
|
||
{{ selectedRun ? `当前查看会话:${selectedRun.run_id}` : "当前暂无检测会话,日志窗口会显示最近 Worker 输出" }}
|
||
</div>
|
||
</div>
|
||
<div class="task-detail-meta">
|
||
<el-switch v-model="logAutoFollow" inline-prompt active-text="跟随日志" inactive-text="暂停跟随" size="small" />
|
||
<el-button text size="small" @click="scrollLogToBottom(true)">回到底部</el-button>
|
||
<span>{{ isAggregateDetectView ? "参与节点" : "本机 Worker" }}:{{ isAggregateDetectView ? participatingNodeCount : (status.worker_online ? "在线" : "离线") }}</span>
|
||
<span v-if="selectedRun?.phase_label">阶段:{{ selectedRun.phase_label }}</span>
|
||
<span>运行中:{{ displayRunningStat }}</span>
|
||
<span>线程:{{ runtimeThreadSummary }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="selectedRun?.phase_detail" class="phase-detail">{{ selectedRun.phase_detail }}</div>
|
||
<pre ref="logConsoleRef" class="log-console" @scroll="handleLogScroll">{{ selectedLogText }}</pre>
|
||
</div>
|
||
|
||
<div
|
||
v-if="(activeJob?.current_cycle_events || activeJob?.recent_events)?.length"
|
||
class="event-stream-panel"
|
||
>
|
||
<div class="event-stream-header">
|
||
<div>
|
||
<div class="event-stream-title">检测事件流</div>
|
||
<div class="event-stream-subtitle">事件列表单独滚动,避免检测进行越久页面越被撑长。</div>
|
||
</div>
|
||
<span class="event-stream-count">
|
||
{{ (activeJob.current_cycle_events || activeJob.recent_events || []).length }} 条
|
||
</span>
|
||
</div>
|
||
<el-table
|
||
:data="activeJob.current_cycle_events || activeJob.recent_events"
|
||
border
|
||
max-height="320"
|
||
>
|
||
<el-table-column prop="created_at" label="事件时间" min-width="160" />
|
||
<el-table-column prop="node_code" label="节点" min-width="160" />
|
||
<el-table-column prop="event_type" label="事件类型" min-width="160" />
|
||
<el-table-column prop="message" label="事件说明" min-width="320" show-overflow-tooltip />
|
||
</el-table>
|
||
</div>
|
||
|
||
<el-table v-if="phaseHistory.length" :data="phaseHistory" border style="margin-top: 16px">
|
||
<el-table-column prop="at" label="切换时间" min-width="180" />
|
||
<el-table-column prop="label" label="阶段" min-width="120" />
|
||
<el-table-column prop="detail" label="阶段说明" min-width="360" show-overflow-tooltip />
|
||
</el-table>
|
||
</PageCard>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import { ElMessage } from "element-plus";
|
||
import PageCard from "@/components/PageCard.vue";
|
||
import { detectApi, settingsApi } from "@/api/modules";
|
||
|
||
const DETECT_LAST_ACTION_KEY = "domaincheck:detect:last-action";
|
||
const router = useRouter();
|
||
const status = ref({
|
||
worker_online: false,
|
||
worker_mode: "windows-local",
|
||
worker_service_name: "",
|
||
api_service_name: "",
|
||
worker_process_count: 0,
|
||
worker_latest_start_time: "",
|
||
worker_runtime_message: "",
|
||
thread_count: 0,
|
||
thread_count_default: 0,
|
||
thread_count_source: "default",
|
||
thread_count_override: null as number | null,
|
||
thread_count_node_code: "",
|
||
active_thread_count: 0,
|
||
max_thread_count: 0,
|
||
proxy_enable: false,
|
||
allow_direct: false,
|
||
proxy_pool_count: 0,
|
||
available_proxy_count: 0,
|
||
last_worker_log_time: "",
|
||
recent_event: "",
|
||
recent_warning: "",
|
||
proxy_runtime_state: "",
|
||
proxy_runtime_label: "",
|
||
proxy_runtime_detail: "",
|
||
proxy_runtime_reason: "",
|
||
proxy_supplier_empty: false,
|
||
proxy_direct_fallback_active: false,
|
||
proxy_last_refresh_status: "",
|
||
proxy_last_refresh_time: "",
|
||
dependency_alerts: [] as any[],
|
||
aggregate_detect_view: false,
|
||
log_lines: [] as string[],
|
||
remote_log_lines: [] as string[],
|
||
remote_log_line_count: 0,
|
||
remote_log_last_at: "",
|
||
remote_log_last_line: "",
|
||
remote_log_nodes: [] as string[],
|
||
remote_log_node_count: 0,
|
||
runs: [] as any[],
|
||
worker_log_sync_enabled: false,
|
||
worker_log_sync_mode: "key",
|
||
progress_percent: 0,
|
||
active_job: null as any,
|
||
progress: {
|
||
pending: 0,
|
||
running: 0,
|
||
completed: 0,
|
||
failed: 0,
|
||
blacklisted: 0
|
||
}
|
||
});
|
||
|
||
const threadCountSummary = computed(() => {
|
||
const effective = Number(status.value.thread_count || 0);
|
||
const defaultValue = Number(status.value.thread_count_default || 0);
|
||
const nodeCode = String(status.value.thread_count_node_code || "").trim();
|
||
if (status.value.thread_count_source === "node_override" && status.value.thread_count_override) {
|
||
return `${effective}(节点 ${nodeCode || "-"} 单独覆盖,默认 ${defaultValue})`;
|
||
}
|
||
return `${effective}(默认)`;
|
||
});
|
||
const queueSummary = ref({
|
||
window_minutes: 15,
|
||
has_active_job: false,
|
||
queue: {
|
||
items_total: 0,
|
||
terminal: 0,
|
||
terminal_percent: 0,
|
||
overdue_leases: 0,
|
||
expiring_soon_leases: 0,
|
||
oldest_pending_at: "",
|
||
oldest_pending_age_minutes: 0
|
||
},
|
||
throughput: {
|
||
processed_recent: 0,
|
||
processed_per_minute: 0
|
||
},
|
||
nodes: [] as any[]
|
||
});
|
||
|
||
const autoRefresh = ref(true);
|
||
const logAutoFollow = ref(true);
|
||
const loading = ref(false);
|
||
const actionLoading = ref<"" | "start" | "stop">("");
|
||
const lastUpdatedAt = ref("");
|
||
const selectedRunId = ref("");
|
||
const lastAction = ref({
|
||
label: "",
|
||
message: "",
|
||
at: "",
|
||
type: "success" as "success" | "warning" | "info" | "error"
|
||
});
|
||
let timer: number | null = null;
|
||
const logConsoleRef = ref<HTMLElement | null>(null);
|
||
|
||
const persistLastAction = () => {
|
||
if (!lastAction.value.message) {
|
||
window.localStorage.removeItem(DETECT_LAST_ACTION_KEY);
|
||
return;
|
||
}
|
||
window.localStorage.setItem(DETECT_LAST_ACTION_KEY, JSON.stringify(lastAction.value));
|
||
};
|
||
|
||
const restoreLastAction = () => {
|
||
try {
|
||
const raw = window.localStorage.getItem(DETECT_LAST_ACTION_KEY);
|
||
if (!raw) return;
|
||
const parsed = JSON.parse(raw);
|
||
lastAction.value = {
|
||
label: parsed.label || "",
|
||
message: parsed.message || "",
|
||
at: parsed.at || "",
|
||
type: parsed.type || "info"
|
||
};
|
||
} catch {
|
||
window.localStorage.removeItem(DETECT_LAST_ACTION_KEY);
|
||
}
|
||
};
|
||
|
||
const runs = computed(() => status.value.runs || []);
|
||
const activeJob = computed(() => status.value.active_job || null);
|
||
const isAggregateDetectView = computed(() => Boolean(status.value.aggregate_detect_view));
|
||
const activeJobDisplayClaimed = computed(() => Number(activeJob.value?.display_items_claimed ?? activeJob.value?.items_claimed ?? 0));
|
||
const activeJobDisplayRunning = computed(() => Math.max(
|
||
Number(activeJob.value?.items_running ?? 0),
|
||
Number(activeJob.value?.display_active_threads ?? 0),
|
||
));
|
||
const threadSnapshotActive = computed(() => {
|
||
const jobValue = Number(activeJob.value?.display_active_threads ?? 0);
|
||
if (jobValue > 0) {
|
||
return jobValue;
|
||
}
|
||
return Number(status.value.active_thread_count || 0);
|
||
});
|
||
const threadSnapshotMax = computed(() => {
|
||
const jobValue = Number(activeJob.value?.display_max_threads ?? 0);
|
||
if (jobValue > 0) {
|
||
return jobValue;
|
||
}
|
||
return Number(status.value.max_thread_count || status.value.thread_count || 0);
|
||
});
|
||
const displayRunningStat = computed(() => Math.max(Number(status.value.progress.running || 0), activeJobDisplayRunning.value));
|
||
const registerableStat = computed(() => Number(status.value.progress?.registerable || 0));
|
||
const activeJobDisplayNodeStats = computed(() => {
|
||
const distributed = activeJob.value?.distributed_node_stats;
|
||
if (Array.isArray(distributed) && distributed.length) {
|
||
return distributed;
|
||
}
|
||
return Array.isArray(activeJob.value?.node_stats) ? activeJob.value.node_stats : [];
|
||
});
|
||
const participatingNodeCount = computed(() => {
|
||
const rows = activeJobDisplayNodeStats.value.filter((item) => String(item?.node_code || "").trim() && String(item?.node_code || "").trim() !== "unassigned");
|
||
return rows.length || 0;
|
||
});
|
||
const queueProcessedPerMinute = computed(() => Number(queueSummary.value.throughput?.processed_per_minute || 0));
|
||
const queueProcessedPerHour = computed(() => Number((queueProcessedPerMinute.value * 60).toFixed(2)));
|
||
const remainingItemsEstimate = computed(() => {
|
||
const queue = queueSummary.value.queue || {};
|
||
return Number(queue.pending || 0) + Number(queue.claimed || 0) + Number(queue.running || 0);
|
||
});
|
||
const estimatedRemainingMinutes = computed(() => {
|
||
const perMinute = queueProcessedPerMinute.value;
|
||
if (perMinute <= 0) {
|
||
return 0;
|
||
}
|
||
return Number((remainingItemsEstimate.value / perMinute).toFixed(1));
|
||
});
|
||
const estimatedRemainingText = computed(() => {
|
||
if (!queueSummary.value.has_active_job) {
|
||
return "-";
|
||
}
|
||
if (estimatedRemainingMinutes.value <= 0) {
|
||
return "即将完成";
|
||
}
|
||
if (estimatedRemainingMinutes.value >= 60) {
|
||
return `${(estimatedRemainingMinutes.value / 60).toFixed(2)}h`;
|
||
}
|
||
return `${estimatedRemainingMinutes.value}m`;
|
||
});
|
||
const runtimeThreadSummary = computed(() => {
|
||
if (isAggregateDetectView.value) {
|
||
return `${threadSnapshotActive.value} / ${threadSnapshotMax.value}(参与节点汇总)`;
|
||
}
|
||
return threadCountSummary.value;
|
||
});
|
||
const activeJobSummaryText = computed(() => {
|
||
if (!activeJob.value) {
|
||
return "当前暂无活跃检测任务";
|
||
}
|
||
const job = activeJob.value;
|
||
const displayClaimed = Number(job.display_items_claimed ?? job.items_claimed ?? 0);
|
||
const displayRunning = activeJobDisplayRunning.value;
|
||
const displayActiveThreads = Number(job.display_active_threads ?? 0);
|
||
const displayMaxThreads = Number(job.display_max_threads ?? 0);
|
||
return `状态:${jobStatusText(job.status)} / 总数 ${job.items_total || 0} / 待领 ${job.items_pending || 0} / 已领 ${displayClaimed} / 实时执行 ${displayRunning} / 活跃线程 ${displayActiveThreads}/${displayMaxThreads} / 速度 ${queueProcessedPerMinute.value} 项/分钟 / 预计剩余 ${estimatedRemainingText.value}`;
|
||
});
|
||
const selectedRun = computed(() => runs.value.find((item) => item.run_id === selectedRunId.value) || runs.value[0] || null);
|
||
const currentPhaseLabel = computed(() => selectedRun.value?.phase_label || (status.value.worker_online ? "运行中" : "未启动"));
|
||
const currentPhaseDetail = computed(() => {
|
||
if (selectedRun.value?.phase_detail) {
|
||
return selectedRun.value.phase_detail;
|
||
}
|
||
if (status.value.recent_event) {
|
||
return status.value.recent_event;
|
||
}
|
||
return status.value.worker_runtime_message || "当前暂无阶段明细";
|
||
});
|
||
const phaseHistory = computed(() => {
|
||
const items = selectedRun.value?.phase_history;
|
||
if (!Array.isArray(items)) return [];
|
||
return [...items].reverse();
|
||
});
|
||
const selectedRunIsLatest = computed(() => {
|
||
if (!selectedRun.value || !runs.value.length) {
|
||
return false;
|
||
}
|
||
return selectedRun.value.run_id === runs.value[0]?.run_id;
|
||
});
|
||
|
||
const dedupeConsecutiveLines = (lines: string[]) => {
|
||
const result: string[] = [];
|
||
for (const rawLine of lines) {
|
||
const line = String(rawLine || "");
|
||
if (!line) {
|
||
continue;
|
||
}
|
||
if (result[result.length - 1] === line) {
|
||
continue;
|
||
}
|
||
result.push(line);
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const selectedLogText = computed(() => {
|
||
const runLogs = selectedRun.value?.logs;
|
||
const mergedLines: string[] = [];
|
||
if (Array.isArray(runLogs) && runLogs.length) {
|
||
mergedLines.push(...runLogs);
|
||
}
|
||
const remoteLogLines = status.value.remote_log_lines || [];
|
||
const canAppendRemoteLogs = !selectedRun.value || selectedRunIsLatest.value;
|
||
if (canAppendRemoteLogs && Array.isArray(remoteLogLines) && remoteLogLines.length) {
|
||
mergedLines.push(...remoteLogLines);
|
||
}
|
||
if (mergedLines.length) {
|
||
return dedupeConsecutiveLines(mergedLines).join("\n");
|
||
}
|
||
const liveLogs = status.value.log_lines || [];
|
||
return liveLogs.length ? dedupeConsecutiveLines(liveLogs).join("\n") : "暂无检测日志";
|
||
});
|
||
const proxyAlertType = computed<"success" | "warning" | "info" | "error">(() => {
|
||
if (status.value.proxy_runtime_state === "healthy") return "success";
|
||
if (status.value.proxy_runtime_state === "degraded_direct") return "warning";
|
||
if (status.value.proxy_runtime_state === "blocked_no_proxy") return "error";
|
||
return "info";
|
||
});
|
||
|
||
const proxyDiagnosisText = computed(() => {
|
||
if (status.value.proxy_supplier_empty) {
|
||
return "供应池为空,系统已按配置继续运行";
|
||
}
|
||
return status.value.proxy_runtime_label || "当前可用代理 / 配置代理池链接数";
|
||
});
|
||
|
||
const remoteLogMirrorText = computed(() => {
|
||
if (!status.value.worker_log_sync_enabled) {
|
||
return "当前已关闭远端日志回传;日志控制台只展示本机 Worker 输出,不会拼接其他节点的执行过程。";
|
||
}
|
||
const modeText = status.value.worker_log_sync_mode === "full" ? "全量" : "关键";
|
||
const nodeCount = Number(status.value.remote_log_node_count || 0);
|
||
const lineCount = Number(status.value.remote_log_line_count || 0);
|
||
const nodes = Array.isArray(status.value.remote_log_nodes) ? status.value.remote_log_nodes : [];
|
||
const lastAt = String(status.value.remote_log_last_at || "").trim();
|
||
const parts = [
|
||
`当前已开启${modeText}回传,日志控制台会自动拼接远端节点回传日志。`,
|
||
`来源节点 ${nodeCount} 台`,
|
||
`样本 ${lineCount} 条`,
|
||
];
|
||
if (nodes.length) {
|
||
parts.push(`节点:${nodes.join("、")}`);
|
||
}
|
||
if (lastAt) {
|
||
parts.push(`最近回传:${lastAt}`);
|
||
}
|
||
return parts.join(";");
|
||
});
|
||
const detectScopeSummaryText = computed(() => {
|
||
if (isAggregateDetectView.value) {
|
||
return `当前检测状态:${currentPhaseLabel};当前页展示的是多节点汇总运行态,参与节点 ${participatingNodeCount.value} 台,实时活跃线程 ${threadSnapshotActive.value}/${threadSnapshotMax.value}。`;
|
||
}
|
||
return `当前检测状态:${currentPhaseLabel};本机 Worker ${status.value.worker_online ? "在线" : "离线"}。`;
|
||
});
|
||
|
||
const queueAlertType = computed<"success" | "warning" | "info" | "error">(() => {
|
||
if (!queueSummary.value.has_active_job) return "info";
|
||
if ((queueSummary.value.queue?.overdue_leases || 0) > 0) return "error";
|
||
if ((queueSummary.value.queue?.expiring_soon_leases || 0) > 0 || (queueSummary.value.queue?.oldest_pending_age_minutes || 0) >= 30) return "warning";
|
||
return "success";
|
||
});
|
||
|
||
const queueAlertText = computed(() => {
|
||
if (!queueSummary.value.has_active_job) {
|
||
return "当前没有活跃检测任务,队列健康度会在下一轮任务创建后开始统计。";
|
||
}
|
||
const queue = queueSummary.value.queue || {};
|
||
const throughput = queueSummary.value.throughput || {};
|
||
const displayClaimed = activeJobDisplayClaimed.value || Number(queue.display_claimed ?? queue.claimed ?? 0);
|
||
const displayRunning = displayRunningStat.value || Number(queue.display_running ?? queue.running ?? 0);
|
||
const parts = [
|
||
`当前队列待领 ${queue.pending || 0} / 已领 ${displayClaimed} / 任务执行中 ${displayRunning}`,
|
||
`近 ${queueSummary.value.window_minutes || 15} 分钟处理 ${throughput.processed_recent || 0} 项,约 ${throughput.processed_per_minute || 0} 项/分钟`
|
||
];
|
||
if ((queue.overdue_leases || 0) > 0) {
|
||
parts.push(`存在 ${queue.overdue_leases} 个过期租约,建议立即检查 Worker 心跳和数据库续租`);
|
||
} else if ((queue.expiring_soon_leases || 0) > 0) {
|
||
parts.push(`有 ${queue.expiring_soon_leases} 个任务租约将在 5 分钟内到期`);
|
||
}
|
||
if ((queue.oldest_pending_age_minutes || 0) > 0) {
|
||
parts.push(`最老待领任务已等待 ${queue.oldest_pending_age_minutes} 分钟`);
|
||
}
|
||
return parts.join(";");
|
||
});
|
||
|
||
const scrollLogToBottom = async (force = false) => {
|
||
if (!force && !logAutoFollow.value) {
|
||
return;
|
||
}
|
||
await nextTick();
|
||
const element = logConsoleRef.value;
|
||
if (!element) {
|
||
return;
|
||
}
|
||
element.scrollTop = element.scrollHeight;
|
||
if (force) {
|
||
logAutoFollow.value = true;
|
||
}
|
||
};
|
||
|
||
const handleLogScroll = () => {
|
||
const element = logConsoleRef.value;
|
||
if (!element) {
|
||
return;
|
||
}
|
||
const distanceToBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||
logAutoFollow.value = distanceToBottom < 24;
|
||
};
|
||
|
||
const statusText = (value: string) => {
|
||
if (value === "starting") return "启动中";
|
||
if (value === "running") return "运行中";
|
||
if (value === "stopping") return "停止中";
|
||
if (value === "completed") return "已完成";
|
||
if (value === "stopped") return "已停止";
|
||
if (value === "failed") return "失败";
|
||
if (value === "partial_failed") return "部分失败";
|
||
return value || "-";
|
||
};
|
||
|
||
const jobStatusText = (value: string) => {
|
||
if (value === "pending") return "待执行";
|
||
if (value === "running") return "执行中";
|
||
if (value === "completed") return "已完成";
|
||
if (value === "partial_failed") return "部分失败";
|
||
if (value === "failed") return "失败";
|
||
return value || "-";
|
||
};
|
||
|
||
const statusTagType = (value: string) => {
|
||
if (value === "running") return "success";
|
||
if (value === "starting" || value === "stopping") return "warning";
|
||
if (value === "completed") return "success";
|
||
if (value === "partial_failed") return "warning";
|
||
if (value === "failed") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const phaseTagType = (phaseLabel: string, statusValue: string) => {
|
||
if (statusValue === "failed") return "danger";
|
||
if (statusValue === "partial_failed") return "warning";
|
||
if (statusValue === "starting" || statusValue === "stopping") return "warning";
|
||
if (["检测中", "完成归档", "批次完成", "已完成"].includes(phaseLabel)) return "success";
|
||
if (["刷新代理池", "取任务中", "建线程中", "准备检测", "外部站点异常", "网络波动", "部分失败"].includes(phaseLabel)) return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const clearRefreshTimer = () => {
|
||
if (timer) {
|
||
window.clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
};
|
||
|
||
const ensureRefreshTimer = () => {
|
||
clearRefreshTimer();
|
||
if (autoRefresh.value) {
|
||
timer = window.setInterval(() => {
|
||
loadStatus(false);
|
||
}, 5000);
|
||
}
|
||
};
|
||
|
||
const loadStatus = async (showError = true) => {
|
||
loading.value = true;
|
||
try {
|
||
const [statusResponse, queueResponse] = await Promise.all([
|
||
detectApi.status(),
|
||
detectApi.queueSummary({ window_minutes: 15 })
|
||
]);
|
||
status.value = statusResponse.data;
|
||
queueSummary.value = queueResponse.data;
|
||
if (!selectedRunId.value && runs.value.length) {
|
||
selectedRunId.value = runs.value[0].run_id;
|
||
} else if (selectedRunId.value && !runs.value.some((item) => item.run_id === selectedRunId.value) && runs.value.length) {
|
||
selectedRunId.value = runs.value[0].run_id;
|
||
}
|
||
lastUpdatedAt.value = new Date().toLocaleString("zh-CN", { hour12: false });
|
||
} catch {
|
||
if (showError) {
|
||
ElMessage.error("读取检测状态失败");
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const invoke = async (action: "start" | "stop") => {
|
||
actionLoading.value = action;
|
||
try {
|
||
const response = action === "start" ? await detectApi.start() : await detectApi.stop();
|
||
const responseMessage = String(response.message || "");
|
||
const uiLevel = String(response.data?.ui_level || "").trim().toLowerCase();
|
||
const actionType: "success" | "warning" | "error" =
|
||
uiLevel === "error" ? "error" : uiLevel === "warning" ? "warning" : "success";
|
||
if (actionType === "error") {
|
||
ElMessage.error(responseMessage);
|
||
} else if (actionType === "warning") {
|
||
ElMessage.warning(responseMessage);
|
||
} else {
|
||
ElMessage.success(responseMessage);
|
||
}
|
||
lastAction.value = {
|
||
label: action === "start" ? "检测启动" : "检测停止",
|
||
message: responseMessage,
|
||
at: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||
type: actionType
|
||
};
|
||
persistLastAction();
|
||
await loadStatus(false);
|
||
const pollSchedule = Array.isArray(response.data?.poll_schedule_seconds)
|
||
? response.data.poll_schedule_seconds
|
||
: [Number(response.data?.poll_after_seconds || 2)];
|
||
[...new Set(pollSchedule.map((item: unknown) => Number(item || 0)).filter((item: number) => item > 0))]
|
||
.sort((a, b) => a - b)
|
||
.forEach((seconds) => {
|
||
window.setTimeout(() => {
|
||
loadStatus(false);
|
||
}, seconds * 1000);
|
||
});
|
||
} catch (error: any) {
|
||
const message = error?.message || `${action === "start" ? "启动" : "停止"}操作失败`;
|
||
lastAction.value = {
|
||
label: action === "start" ? "检测启动" : "检测停止",
|
||
message,
|
||
at: new Date().toLocaleString("zh-CN", { hour12: false }),
|
||
type: "error"
|
||
};
|
||
persistLastAction();
|
||
ElMessage.error(message);
|
||
} finally {
|
||
actionLoading.value = "";
|
||
}
|
||
};
|
||
|
||
const handleCurrentRunChange = (row: any | null) => {
|
||
selectedRunId.value = row?.run_id || "";
|
||
};
|
||
|
||
const goRuntime = () => {
|
||
router.push("/runtime");
|
||
};
|
||
|
||
const updateWorkerLogSync = async (enabled: boolean, mode: "key" | "full") => {
|
||
try {
|
||
const current = await settingsApi.getSettings();
|
||
const payload = {
|
||
...current.data,
|
||
runtime_settings: {
|
||
...(current.data?.runtime_settings || {}),
|
||
worker_log_sync_enabled: enabled,
|
||
worker_log_sync_mode: mode
|
||
}
|
||
};
|
||
await settingsApi.updateSettings(payload);
|
||
ElMessage.success(`远端日志回传已${enabled ? `切到${mode === "full" ? "全量" : "关键"}模式` : "关闭"}`);
|
||
await loadStatus();
|
||
} catch {
|
||
ElMessage.error("更新远端日志回传开关失败");
|
||
}
|
||
};
|
||
|
||
watch(autoRefresh, ensureRefreshTimer);
|
||
watch(selectedLogText, async (current, previous) => {
|
||
if (current === previous) {
|
||
return;
|
||
}
|
||
await scrollLogToBottom();
|
||
});
|
||
watch(selectedRunId, async () => {
|
||
await scrollLogToBottom(true);
|
||
});
|
||
watch(logAutoFollow, async (enabled) => {
|
||
if (enabled) {
|
||
await scrollLogToBottom(true);
|
||
}
|
||
});
|
||
|
||
onMounted(async () => {
|
||
restoreLastAction();
|
||
await loadStatus();
|
||
ensureRefreshTimer();
|
||
await scrollLogToBottom(true);
|
||
});
|
||
|
||
onBeforeUnmount(clearRefreshTimer);
|
||
</script>
|
||
|
||
<style scoped>
|
||
.actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.action-alert {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.updated-at {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.summary-card {
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 14px;
|
||
padding: 14px 16px;
|
||
background: #f8fbff;
|
||
}
|
||
|
||
.summary-title {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.summary-value {
|
||
margin: 8px 0 10px;
|
||
color: #0f172a;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.phase-value {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.summary-note {
|
||
margin-top: 8px;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.summary-grid-tight {
|
||
margin-top: -4px;
|
||
}
|
||
|
||
.history-panel {
|
||
margin: 16px 0;
|
||
}
|
||
|
||
.history-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.history-header h3 {
|
||
margin: 0;
|
||
}
|
||
|
||
.history-note {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.task-code,
|
||
.event-value {
|
||
font-size: 18px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.task-detail {
|
||
margin: 16px 0;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
border: 1px solid #1f2937;
|
||
background: #020617;
|
||
}
|
||
|
||
.task-detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: #111827;
|
||
color: #e5e7eb;
|
||
}
|
||
|
||
.task-detail-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.task-detail-subtitle,
|
||
.task-detail-meta {
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.task-detail-meta {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.log-console {
|
||
margin: 0;
|
||
min-height: 160px;
|
||
max-height: 260px;
|
||
overflow: auto;
|
||
padding: 16px;
|
||
color: #e0e7ff;
|
||
background: #020617;
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
font-family: Consolas, Monaco, monospace;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.phase-detail {
|
||
padding: 10px 16px 0;
|
||
color: #93c5fd;
|
||
font-size: 12px;
|
||
background: #020617;
|
||
}
|
||
|
||
.event-stream-panel {
|
||
margin: 16px 0;
|
||
}
|
||
|
||
.event-stream-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.event-stream-title {
|
||
color: #0f172a;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.event-stream-subtitle {
|
||
margin-top: 4px;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.event-stream-count {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|