This commit is contained in:
Your Name
2026-04-22 14:13:21 +08:00
parent e0406b5d0e
commit 7cbde2aa78
145 changed files with 23086 additions and 2243 deletions

View File

@@ -16,7 +16,7 @@
<header class="topbar">
<div>
<h1>{{ title }}</h1>
<p>当前保持桌面版不变Web 管理后台进入可持续运维阶段</p>
<p>{{ pageDescription }}</p>
</div>
<div class="topbar-actions">
@@ -68,6 +68,7 @@ const menuItems = [
{ path: "/runtime", label: "运行中心" },
{ path: "/runtime-debug", label: "运行调试" },
{ path: "/ops-center", label: "运维中枢" },
{ path: "/managed-nodes", label: "托管节点" },
{ path: "/settings", label: "系统设置" },
{ path: "/imports", label: "域名导入" },
{ path: "/sensitive-words", label: "敏感词配置" },
@@ -79,6 +80,13 @@ const menuItems = [
];
const title = computed(() => String(route.meta?.title || "概览"));
const pageDescription = computed(
() =>
String(
route.meta?.description ||
"当前保持桌面版不变Web 管理后台进入可持续运维阶段。"
)
);
const loadRuntimeSummary = async () => {
try {
@@ -258,7 +266,9 @@ onBeforeUnmount(() => {
flex: 1;
min-height: 0;
padding: 0 28px 28px;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 960px) {

View File

@@ -13,18 +13,19 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/layouts/MainLayout.vue"),
children: [
{ path: "", redirect: "/dashboard" },
{ path: "dashboard", name: "dashboard", component: () => import("@/views/dashboard/DashboardView.vue"), meta: { title: "概览" } },
{ path: "runtime", name: "runtime", component: () => import("@/views/runtime/RuntimeView.vue"), meta: { title: "运行中心" } },
{ path: "runtime-debug", name: "runtime-debug", component: () => import("@/views/runtime/RuntimeDebugView.vue"), meta: { title: "运行调试" } },
{ path: "ops-center", name: "ops-center", component: () => import("@/views/ops/OpsCenterView.vue"), meta: { title: "运维中枢" } },
{ path: "settings", name: "settings", component: () => import("@/views/settings/SettingsView.vue"), meta: { title: "系统设置" } },
{ path: "imports", name: "imports", component: () => import("@/views/imports/ImportsView.vue"), meta: { title: "域名导入" } },
{ path: "sensitive-words", name: "sensitive-words", component: () => import("@/views/sensitive-words/SensitiveWordsView.vue"), meta: { title: "敏感词配置" } },
{ path: "juming", name: "juming", component: () => import("@/views/juming/JumingView.vue"), meta: { title: "聚名采集" } },
{ path: "detect", name: "detect", component: () => import("@/views/detect/DetectView.vue"), meta: { title: "检测控制" } },
{ path: "domains", name: "domains", component: () => import("@/views/domains/DomainsView.vue"), meta: { title: "域名筛选" } },
{ path: "exports", name: "exports", component: () => import("@/views/exports/ExportsView.vue"), meta: { title: "导出中心" } },
{ path: "logs", name: "logs", component: () => import("@/views/logs/LogsView.vue"), meta: { title: "日志诊断" } }
{ path: "dashboard", name: "dashboard", component: () => import("@/views/dashboard/DashboardView.vue"), meta: { title: "概览", description: "统一查看当前任务、吞吐、瓶颈步骤和整体运行态。" } },
{ path: "runtime", name: "runtime", component: () => import("@/views/runtime/RuntimeView.vue"), meta: { title: "运行中心", description: "看检测任务有没有真正跑起来,重点盯 running、ppm、步骤队列和节点负载。" } },
{ path: "runtime-debug", name: "runtime-debug", component: () => import("@/views/runtime/RuntimeDebugView.vue"), meta: { title: "运行调试", description: "排查步骤卡点、事件流、调试日志和运行时异常。" } },
{ path: "ops-center", name: "ops-center", component: () => import("@/views/ops/OpsCenterView.vue"), meta: { title: "运维中枢", description: "这里是总驾驶舱,负责发布、运维任务、巡检、接管进度和整体运维视角。" } },
{ path: "managed-nodes", name: "managed-nodes", component: () => import("@/views/ops/ManagedNodesView.vue"), meta: { title: "托管节点", description: "这里专门管理机器本身:新增节点、填 SSH、启用停用、维护接入信息。" } },
{ path: "settings", name: "settings", component: () => import("@/views/settings/SettingsView.vue"), meta: { title: "系统设置", description: "维护系统基础配置、运行参数和默认行为。" } },
{ path: "imports", name: "imports", component: () => import("@/views/imports/ImportsView.vue"), meta: { title: "域名导入", description: "导入待检测域名数据,建立任务源。" } },
{ path: "sensitive-words", name: "sensitive-words", component: () => import("@/views/sensitive-words/SensitiveWordsView.vue"), meta: { title: "敏感词配置", description: "维护过滤词、黑名单词和筛选规则。" } },
{ path: "juming", name: "juming", component: () => import("@/views/juming/JumingView.vue"), meta: { title: "聚名采集", description: "抓取聚名删除列表,跟踪采集、去重和入库进度。" } },
{ path: "detect", name: "detect", component: () => import("@/views/detect/DetectView.vue"), meta: { title: "检测控制", description: "启动检测、观察步骤推进,并排查并发和补位问题。" } },
{ path: "domains", name: "domains", component: () => import("@/views/domains/DomainsView.vue"), meta: { title: "域名筛选", description: "查看域名状态、筛选结果和流程产出。" } },
{ path: "exports", name: "exports", component: () => import("@/views/exports/ExportsView.vue"), meta: { title: "导出中心", description: "管理导出任务和结果文件。" } },
{ path: "logs", name: "logs", component: () => import("@/views/logs/LogsView.vue"), meta: { title: "日志诊断", description: "集中查看系统日志、错误信息和诊断输出。" } }
]
}
];

View File

@@ -24,18 +24,107 @@
</PageCard>
</div>
<PageCard title="第一期目标" description="先把运营最需要的控制面搬到 Web再逐步把检测执行和运维能力完全迁到 Linux。">
<el-timeline>
<el-timeline-item timestamp="当前阶段" placement="top">已创建 Web 后台API运行中心和导入/导出/筛选主链路</el-timeline-item>
<el-timeline-item timestamp="下一步" placement="top">继续补 Linux Worker 控制闭环和更完整的运行诊断</el-timeline-item>
<el-timeline-item timestamp="后续阶段" placement="top">把检测端逐步迁为 Linux Worker前后端彻底分离</el-timeline-item>
</el-timeline>
<PageCard
title="运营总览"
description="先给运营一个能判断有没有跑起来、卡在哪一步、还要跑多久的最小面板。"
>
<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">
{{
activeJob.job_code
? `状态 ${activeJob.status || "-"} / 当前任务总数 ${activeJob.items_total || 0} / 当前累计活跃任务 ${opsSummary.active_jobs_total || 0}`
: "当前没有活跃检测任务"
}}
</div>
</div>
<div class="summary-card">
<div class="summary-title">当前速度</div>
<div class="summary-value">{{ processedPerMinuteText }}</div>
<div class="summary-note">近窗完成 {{ opsSummary.completed_recent || 0 }} / 失败 {{ opsSummary.failed_recent || 0 }} / 黑名单 {{ opsSummary.blacklisted_recent || 0 }}</div>
</div>
<div class="summary-card">
<div class="summary-title">预计剩余</div>
<div class="summary-value">{{ etaText }}</div>
<div class="summary-note">按当前吞吐推算剩余 {{ opsSummary.remaining_items || 0 }} </div>
</div>
<div class="summary-card">
<div class="summary-title">瓶颈步骤</div>
<div class="summary-value">{{ bottleneckStep.step_name || "-" }}</div>
<div class="summary-note">待处理 {{ bottleneckStep.items_pending || 0 }} / 执行中 {{ bottleneckStep.items_running || 0 }}</div>
</div>
<div class="summary-card">
<div class="summary-title">真实积压</div>
<div class="summary-value">{{ backlogPendingText }}</div>
<div class="summary-note">注册待处理 {{ backlogRegisterPendingText }} / 后续步骤 {{ backlogDownstreamPendingText }}</div>
</div>
<div class="summary-card">
<div class="summary-title">有效执行节点</div>
<div class="summary-value">{{ opsSummary.active_execution_nodes || 0 }}</div>
<div class="summary-note">在线 Worker {{ opsSummary.online_worker_nodes || 0 }} / 独立 Worker {{ opsSummary.dedicated_online_worker_nodes || 0 }} / 当前线程负载 {{ queueDisplayRunningText }}</div>
</div>
</div>
<el-row v-if="activeJob.job_code" :gutter="12" class="stat-row">
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务待处理" :value="activeJob.items_pending || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前线程负载" :value="activeJobDisplayRunning" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务执行中" :value="activeJob.items_running || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务通过" :value="activeJob.items_completed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务黑名单" :value="activeJob.items_blacklisted || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务失败" :value="activeJob.items_failed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前进度" :value="activeJob.progress_percent || 0" suffix="%" /></el-col>
</el-row>
<el-alert
:closable="false"
show-icon
:type="activeJob.job_code ? 'info' : 'warning'"
:title="activeJob.job_code ? opsHeadline : '当前没有活跃检测任务,运营面板暂无可分析数据。'"
/>
</PageCard>
<PageCard title="步骤队列" description="看每一步堆积、吞吐和失败快速判断到底卡在注册、百度、360、爱站还是站长之家。">
<el-table :data="stepQueue" border empty-text="当前没有活跃步骤队列">
<el-table-column prop="step_name" label="步骤" min-width="150" />
<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="90" />
<el-table-column prop="items_completed" label="完成" min-width="90" />
<el-table-column prop="items_blacklisted" label="黑名单" min-width="90" />
<el-table-column prop="items_failed" label="失败" min-width="90" />
<el-table-column prop="started_recent" label="近窗启动" min-width="100" />
<el-table-column prop="processed_recent" label="近窗处理" min-width="100" />
<el-table-column prop="completed_recent" label="近窗完成" min-width="100" />
<el-table-column prop="failed_recent" label="近窗失败" min-width="100" />
<el-table-column prop="processed_per_minute" label="项/分钟" min-width="100" />
</el-table>
</PageCard>
<PageCard title="节点吞吐" description="看 controller 和 worker 是不是都真在跑,谁在空转,谁在出结果。">
<el-table :data="nodeThroughput" border empty-text="当前没有节点吞吐数据">
<el-table-column prop="node_code" label="节点" min-width="180" />
<el-table-column prop="items_running" label="任务执行中" min-width="110" />
<el-table-column prop="display_running" label="线程负载" min-width="100" />
<el-table-column label="活跃线程" min-width="120">
<template #default="{ row }">
{{ formatThreadState(row) }}
</template>
</el-table-column>
<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="failed_recent" label="失败" min-width="90" />
<el-table-column prop="blacklisted_recent" label="黑名单" min-width="90" />
</el-table>
</PageCard>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import PageCard from "@/components/PageCard.vue";
import { dashboardApi } from "@/api/modules";
@@ -47,14 +136,31 @@ let timer: number | null = null;
const stats = ref([
{ label: "总域名", value: "--", note: "数据库域名总量" },
{ label: "待检测", value: "--", note: "当前待处理域名" },
{ label: "黑名单", value: "--", note: "命中风险域名" },
{ label: "库存待检测", value: "--", note: "全库尚未跑完的域名" },
{ label: "库存检测中", value: "--", note: "全库当前处理中域名" },
{ label: "库存检测通过", value: "--", note: "全库已检测通过域名" },
{ label: "库存黑名单", value: "--", note: "全库已命中黑名单域名" },
{ label: "库存失败", value: "--", note: "全库检测失败域名" },
{ label: "可注册", value: "--", note: "检测通过且注册状态为可注册" },
{ label: "可购买", value: "--", note: "检测通过 + 非黑名单 + 可注册 + 未使用" },
{ label: "当前队列待处理", value: "--", note: "当前活跃任务队列 pending" },
{ label: "当前队列已领", value: "--", note: "当前活跃任务已领未跑" },
{ label: "当前队列执行中", value: "--", note: "当前活跃任务 running" },
{ label: "当前队列已完成", value: "--", note: "当前活跃任务 completed" },
{ label: "当前速度", value: "--", note: "近窗处理项/分钟" },
{ label: "预计剩余", value: "--", note: "按当前吞吐估算" },
{ label: "API", value: "离线", note: "当前 API 运行状态" },
{ label: "本机 Worker", value: "离线", note: "当前节点本机 Worker 运行状态" },
{ label: "有效执行节点", value: "离线", note: "当前集群可承担检测任务的在线节点" },
{ label: "模式", value: "--", note: "当前 Worker 运行模式" }
]);
const activeJob = ref<Record<string, any>>({});
const opsSummary = ref<Record<string, any>>({});
const bottleneckStep = ref<Record<string, any>>({});
const stepQueue = ref<Record<string, any>[]>([]);
const nodeThroughput = ref<Record<string, any>[]>([]);
const clearRefreshTimer = () => {
if (timer) {
window.clearInterval(timer);
@@ -76,6 +182,43 @@ const formatNow = () =>
hour12: false
});
const processedPerMinuteText = computed(() => {
const value = Number(opsSummary.value.processed_per_minute || 0);
return value > 0 ? value.toFixed(value >= 10 ? 0 : 2) : "0";
});
const activeJobDisplayRunning = computed(() => Number(activeJob.value.items_display_running || activeJob.value.items_running || 0));
const queueDisplayRunningText = computed(() => String(Number(opsSummary.value.queue_display_running_total || 0)));
const backlogPendingText = computed(() => String(Number(opsSummary.value.backlog_pending_total || 0)));
const backlogRegisterPendingText = computed(() => String(Number(opsSummary.value.backlog_register_pending_total || 0)));
const backlogDownstreamPendingText = computed(() => String(Number(opsSummary.value.backlog_downstream_pending_total || 0)));
const etaText = computed(() => {
const hours = Number(opsSummary.value.estimated_hours_remaining || 0);
if (hours <= 0) return "待计算";
if (hours < 1) return `${Math.max(1, Math.round(hours * 60))} 分钟`;
return `${hours.toFixed(hours >= 10 ? 0 : 1)} 小时`;
});
const opsHeadline = computed(() => {
const jobCode = String(activeJob.value.job_code || "-");
const ppm = processedPerMinuteText.value;
const eta = etaText.value;
const stepName = String(bottleneckStep.value.step_name || "-");
return `当前任务 ${jobCode},真实积压 ${backlogPendingText.value},近窗速度 ${ppm} 项/分钟,预计剩余 ${eta},当前最重步骤 ${stepName}`;
});
const formatThreadState = (row: Record<string, any>) => {
const active = Number(row?.active_threads || 0);
const max = Number(row?.max_threads || 0);
if (max > 0) return `${active}/${max}`;
return String(active);
};
const loadOverview = async (showError = true) => {
loading.value = true;
try {
@@ -88,10 +231,38 @@ const loadOverview = async (showError = true) => {
data.local_worker_status === "not-applicable"
? `当前节点 ${data.node_region || "-"} / ${data.node_role || "-"},默认不承载本机 Worker`
: "当前节点本机 Worker 运行状态";
activeJob.value = data.active_job || {};
opsSummary.value = {
...(data.ops_summary || {}),
retry_total: data.retry_total || 0,
backlog_pending_total: data.backlog_pending_total || 0,
backlog_claimed_total: data.backlog_claimed_total || 0,
backlog_running_total: data.backlog_running_total || 0,
backlog_register_pending_total: data.backlog_register_pending_total || 0,
backlog_downstream_pending_total: data.backlog_downstream_pending_total || 0,
queue_display_running_total: data.queue_display_running_total || 0
};
bottleneckStep.value = data.bottleneck_step || {};
stepQueue.value = Array.isArray(data.step_queue) ? data.step_queue : [];
nodeThroughput.value = Array.isArray(data.node_throughput) ? data.node_throughput : [];
stats.value = [
{ label: "总域名", value: String(data.domains_total), note: "数据库域名总量" },
{ label: "待检测", value: String(data.pending_total), note: "当前待处理域名" },
{ label: "黑名单", value: String(data.blacklist_total), note: "命中风险域名" },
{ label: "库存待检测", value: String(data.pending_total), note: "全库尚未跑完的域名" },
{ label: "库存检测通过", value: String(data.completed_total), note: "全库已检测通过域名" },
{ label: "库存黑名单", value: String(data.blacklist_total), note: "海外主库最终账本中的黑名单域名" },
{ label: "库存失败", value: String(data.failed_total || 0), note: "全库检测失败域名" },
{ label: "可注册", value: String(data.registerable_total || 0), note: "以海外主库最终账本为准,检测通过且注册状态为可注册" },
{ label: "可购买", value: String(data.purchasable_total || 0), note: "可注册 + 非黑名单 + 未使用,以海外主库最终账本为准" },
{ label: "真实积压待处理", value: String(data.backlog_pending_total || 0), note: "大陆执行面真实 backlog pending" },
{ label: "注册待处理", value: String(data.backlog_register_pending_total || 0), note: "第一步 register 积压" },
{ label: "后续步骤待处理", value: String(data.backlog_downstream_pending_total || 0), note: "百度/360/站长/爱站等后续步骤积压" },
{ label: "当前任务待处理", value: String(data.queue_pending_total || 0), note: "当前展示任务的 pending" },
{ label: "当前队列已领", value: String(data.queue_claimed_total || 0), note: "当前活跃任务已领未跑" },
{ label: "当前任务执行中", value: String(data.queue_running_total || 0), note: "当前展示任务真实 running 项" },
{ label: "当前线程负载", value: String(data.queue_display_running_total || 0), note: "当前活跃线程/负载,更接近机器真实忙碌度" },
{ label: "当前队列已完成", value: String(data.queue_completed_total || 0), note: "当前活跃任务 completed" },
{ label: "当前速度", value: processedPerMinuteText.value, note: `近窗处理 ${opsSummary.value.processed_recent || 0}` },
{ label: "预计剩余", value: etaText.value, note: `剩余 ${opsSummary.value.remaining_items || 0}` },
{ label: "API", value: data.api_status, note: "当前 API 运行状态" },
{ label: "本机 Worker", value: localWorkerValue, note: localWorkerNote },
{
@@ -143,7 +314,7 @@ onBeforeUnmount(clearRefreshTimer);
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
@@ -158,7 +329,7 @@ onBeforeUnmount(clearRefreshTimer);
}
.stat-item strong {
font-size: 32px;
font-size: 28px;
color: #0f172a;
}
@@ -166,9 +337,65 @@ onBeforeUnmount(clearRefreshTimer);
color: #94a3b8;
}
@media (max-width: 960px) {
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.summary-card {
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 16px;
background: linear-gradient(180deg, #fff 0%, #f8fafc 100%);
}
.summary-title {
color: #64748b;
font-size: 13px;
margin-bottom: 8px;
}
.summary-value {
font-size: 28px;
font-weight: 700;
color: #0f172a;
}
.summary-note {
margin-top: 8px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.task-code {
font-size: 22px;
word-break: break-all;
}
.stat-row {
margin-bottom: 16px;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
.stats-grid,
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.stats-grid,
.summary-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
</style>

View File

@@ -29,8 +29,10 @@
<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="本机线程配置">{{ 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>
@@ -97,7 +99,7 @@
:closable="false"
show-icon
type="info"
:title="`当前检测状态:${currentPhaseLabel};本机 Worker ${status.worker_online ? '在线' : '离线'}。`"
:title="detectScopeSummaryText"
style="margin-top: 12px"
/>
<el-alert
@@ -110,11 +112,11 @@
<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="status.progress.running || 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="runs.length" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="可注册" :value="registerableStat" /></el-col>
</el-row>
<div class="summary-grid">
@@ -134,9 +136,19 @@
<div class="summary-note">{{ currentPhaseDetail }}</div>
</div>
<div class="summary-card">
<div class="summary-title">线程快照</div>
<div class="summary-value">{{ status.active_thread_count || 0 }} / {{ status.max_thread_count || status.thread_count || 0 }}</div>
<div class="summary-note">当前活跃线程 / 配置线程上限</div>
<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>
@@ -149,6 +161,7 @@
<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>
@@ -205,7 +218,7 @@
<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="90" />
<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" />
@@ -220,18 +233,17 @@
<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="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 v-if="(activeJob?.current_cycle_events || activeJob?.recent_events)?.length" :data="activeJob.current_cycle_events || activeJob.recent_events" border style="margin-bottom: 16px">
<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>
<el-table :data="runs" border highlight-current-row @current-change="handleCurrentRunChange">
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
@@ -275,16 +287,41 @@
<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>本机 Worker{{ status.worker_online ? "在线" : "离线" }}</span>
<span>{{ isAggregateDetectView ? "参与节点" : "本机 Worker" }}{{ isAggregateDetectView ? participatingNodeCount : (status.worker_online ? "在线" : "离线") }}</span>
<span v-if="selectedRun?.phase_label">阶段{{ selectedRun.phase_label }}</span>
<span>运行中{{ status.progress.running || 0 }}</span>
<span>线程{{ threadCountSummary }}</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" />
@@ -333,6 +370,7 @@ const status = ref({
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,
@@ -423,8 +461,28 @@ const restoreLastAction = () => {
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(() => Number(activeJob.value?.display_items_running ?? activeJob.value?.items_running ?? 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) {
@@ -432,14 +490,51 @@ const activeJobDisplayNodeStats = computed(() => {
}
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 = Number(job.display_items_running ?? job.items_running ?? 0);
return `状态:${jobStatusText(job.status)} / 总数 ${job.items_total || 0} / 待领 ${job.items_pending || 0} / 已领 ${displayClaimed} / 执行中 ${displayRunning}`;
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 ? "运行中" : "未启动"));
@@ -532,6 +627,12 @@ const remoteLogMirrorText = computed(() => {
}
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";
@@ -546,10 +647,10 @@ const queueAlertText = computed(() => {
}
const queue = queueSummary.value.queue || {};
const throughput = queueSummary.value.throughput || {};
const displayClaimed = Number(queue.display_claimed ?? queue.claimed ?? 0);
const displayRunning = Number(queue.display_running ?? queue.running ?? 0);
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}`,
`当前队列待领 ${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) {
@@ -889,8 +990,8 @@ onBeforeUnmount(clearRefreshTimer);
.log-console {
margin: 0;
min-height: 320px;
max-height: 520px;
min-height: 160px;
max-height: 260px;
overflow: auto;
padding: 16px;
color: #e0e7ff;
@@ -908,4 +1009,34 @@ onBeforeUnmount(clearRefreshTimer);
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>

View File

@@ -205,7 +205,11 @@
<span v-if="selectedTask?.result">抓取{{ selectedTask.result.domains_found || 0 }}</span>
</div>
</div>
<pre class="log-console">{{ selectedTaskLogs }}</pre>
<div class="task-detail-meta task-detail-meta--console">
<el-switch v-model="logAutoFollow" inline-prompt active-text="跟随日志" inactive-text="暂停跟随" size="small" />
<el-button text size="small" @click="scrollLogToBottom(true)">回到底部</el-button>
</div>
<pre ref="logConsoleRef" class="log-console" @scroll="handleLogScroll">{{ selectedTaskLogs }}</pre>
</div>
<el-row v-if="selectedTask?.result" :gutter="16" style="margin-bottom: 16px">
@@ -229,7 +233,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import type { UploadFile } from "element-plus";
import PageCard from "@/components/PageCard.vue";
@@ -242,11 +246,13 @@ const loggingIn = ref(false);
const linkingJucha = ref(false);
const cookieFile = ref<File | null>(null);
const autoRefresh = ref(true);
const logAutoFollow = ref(true);
const lastUpdatedAt = ref("");
const preferencesSavedAt = ref("");
const preferencesSaving = ref(false);
const tasks = ref<any[]>([]);
const selectedTaskId = ref("");
const logConsoleRef = ref<HTMLElement | null>(null);
let timer: number | null = null;
let savePreferencesTimer: number | null = null;
let suppressPreferenceSave = false;
@@ -600,12 +606,43 @@ const handleCurrentTaskChange = (row: any | null) => {
selectedTaskId.value = row?.task_id || "";
};
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;
};
watch(autoRefresh, ensureRefreshTimer);
watch(
() => [form.mode, form.page_start, form.page_size, form.page_count, form.crawl_date, form.auto_date],
schedulePersistPreferences,
{ deep: false }
);
watch(selectedTaskLogs, () => {
void scrollLogToBottom(false);
});
watch(selectedTaskId, () => {
logAutoFollow.value = true;
void scrollLogToBottom(true);
});
onMounted(async () => {
await Promise.all([refreshAll(), loadCredentials()]);
@@ -728,6 +765,12 @@ onBeforeUnmount(() => {
flex-wrap: wrap;
}
.task-detail-meta--console {
padding: 8px 16px 0;
background: #020617;
justify-content: flex-end;
}
.log-console {
margin: 0;
min-height: 260px;

View File

@@ -0,0 +1,471 @@
<template>
<PageCard title="托管节点" description="专门用于纳管机器、维护 SSH 信息、启停节点。运维中枢继续负责总驾驶舱、任务、发布和巡检。">
<template #header-extra>
<div class="header-actions">
<el-button plain :loading="loading.syncing" @click="syncNodesFromCluster">同步节点</el-button>
<el-button plain :loading="loading.refreshing" @click="loadNodes">刷新</el-button>
<el-button plain @click="goOpsCenter">打开运维中枢</el-button>
<el-button type="primary" @click="openManagedNodeDialog()">新增节点</el-button>
</div>
</template>
<el-alert
:closable="false"
show-icon
type="info"
style="margin-bottom: 16px"
title="这里是独立的节点管理入口。后面你新增机器、填 SSH、启用/停用节点,都从这里做,不用再去运维中枢长页面里找。"
/>
<div class="summary-grid">
<div class="summary-card">
<span class="summary-label">总节点</span>
<strong>{{ summary.total }}</strong>
<span class="summary-note">已纳管 {{ summary.managedTotal }} / 已启用 {{ summary.enabled }}</span>
</div>
<div class="summary-card">
<span class="summary-label">Agent 在线</span>
<strong>{{ summary.online }}</strong>
<span class="summary-note">stale {{ summary.stale }} / 待接入 {{ summary.pendingBootstrap }}</span>
</div>
<div class="summary-card">
<span class="summary-label">执行节点</span>
<strong>{{ summary.participating }}</strong>
<span class="summary-note">在线待命 {{ summary.standby }} / 死信 {{ summary.queueDeadLetterNodes }}</span>
</div>
</div>
<el-table :data="nodes" stripe size="small" height="100%">
<el-table-column label="节点" min-width="220">
<template #default="{ row }">
<div class="node-cell">
<strong>{{ row.node_code || "-" }}</strong>
<span v-if="row.title && row.title !== row.node_code">{{ row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="region" label="地域" width="90" />
<el-table-column prop="role" label="角色" width="90" />
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small">
{{ row.is_enabled ? "已启用" : "已停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="接管态" min-width="120">
<template #default="{ row }">
<el-tag :type="agentStateType(row.agent_state)" size="small">
{{ row.agent_state_label || "未接管" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="SSH" min-width="220">
<template #default="{ row }">
<div class="node-cell">
<span>{{ sshEndpoint(row) }}</span>
<span class="muted">
{{ row.auth_mode || "key" }}
<template v-if="row.ssh_password_configured"> / 已存密码</template>
<template v-else-if="row.ssh_private_key_configured"> / 已存私钥</template>
</span>
</div>
</template>
</el-table-column>
<el-table-column label="最近心跳" min-width="160">
<template #default="{ row }">
{{ row.last_seen_at || row.cluster_last_heartbeat_at || "-" }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" text @click="openManagedNodeDialog(row)">编辑</el-button>
<el-button size="small" text type="primary" @click="toggleNodeEnabled(row)">
{{ row.is_enabled ? "停用" : "启用" }}
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="managedNodeVisible" :title="managedNodeEditing ? '编辑托管节点' : '新增托管节点'" width="760px">
<el-form label-position="top" :model="managedNodeForm" class="dialog-form">
<div class="dialog-grid">
<el-form-item label="节点编码">
<el-input v-model="managedNodeForm.node_code" :disabled="managedNodeEditing" placeholder="例如 mainland-worker-02" />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="managedNodeForm.title" placeholder="例如 上海 worker 02" />
</el-form-item>
<el-form-item label="地域">
<el-select v-model="managedNodeForm.region">
<el-option label="mainland" value="mainland" />
<el-option label="overseas" value="overseas" />
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="managedNodeForm.role">
<el-option label="worker" value="worker" />
<el-option label="control" value="control" />
</el-select>
</el-form-item>
<el-form-item label="SSH 一行录入" class="full-width">
<el-input
v-model="managedNodeForm.ssh_entry"
placeholder="例如 121.204.244.188 root <password> 或 121.204.244.248:22 root <password>"
/>
</el-form-item>
<el-form-item label="SSH Host">
<el-input v-model="managedNodeForm.ssh_host" placeholder="例如 121.204.244.248" />
</el-form-item>
<el-form-item label="SSH Port">
<el-input-number v-model="managedNodeForm.ssh_port" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="SSH User">
<el-input v-model="managedNodeForm.ssh_user" placeholder="例如 root" />
</el-form-item>
<el-form-item label="认证方式">
<el-select v-model="managedNodeForm.auth_mode">
<el-option label="key" value="key" />
<el-option label="password" value="password" />
</el-select>
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="managedNodeForm.is_enabled" active-text="已启用" inactive-text="已停用" />
</el-form-item>
<el-form-item v-if="managedNodeForm.auth_mode === 'password'" label="SSH 密码" class="full-width">
<el-input
v-model="managedNodeForm.ssh_password"
type="password"
show-password
placeholder="留空表示保留当前密码"
/>
<div class="summary-note">当前状态{{ managedNodeForm.ssh_password_configured && !managedNodeForm.clear_ssh_password ? "已保存密码" : "未保存密码" }}</div>
<el-switch v-model="managedNodeForm.clear_ssh_password" active-text="清除已保存密码" inactive-text="保留当前密码" />
</el-form-item>
<el-form-item v-else label="SSH 私钥" class="full-width">
<el-input
v-model="managedNodeForm.ssh_private_key"
type="textarea"
:rows="6"
placeholder="可选:粘贴私钥;留空表示保留当前私钥"
/>
<div class="summary-note">当前状态{{ managedNodeForm.ssh_private_key_configured && !managedNodeForm.clear_ssh_private_key ? "已保存私钥" : "未保存私钥" }}</div>
<el-switch v-model="managedNodeForm.clear_ssh_private_key" active-text="清除已保存私钥" inactive-text="保留当前私钥" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="managedNodeVisible = false">关闭</el-button>
<el-button type="primary" :loading="loading.saving" @click="saveManagedNode">保存节点</el-button>
</div>
</template>
</el-dialog>
</PageCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { opsApi } from "@/api/modules";
type GenericRecord = Record<string, any>;
const router = useRouter();
const loading = ref({
refreshing: false,
syncing: false,
saving: false,
});
const nodes = ref<GenericRecord[]>([]);
const nodesSummary = ref<GenericRecord>({});
const managedNodeVisible = ref(false);
const managedNodeEditing = ref(false);
const managedNodeForm = ref({
node_code: "",
title: "",
region: "mainland",
role: "worker",
ssh_entry: "",
ssh_host: "",
ssh_port: 22,
ssh_user: "root",
auth_mode: "key",
ssh_password: "",
ssh_private_key: "",
ssh_password_configured: false,
ssh_private_key_configured: false,
clear_ssh_password: false,
clear_ssh_private_key: false,
is_enabled: true,
metadata: {} as GenericRecord,
});
const summary = computed(() => ({
total: Number(nodesSummary.value.total || nodes.value.length || 0),
managedTotal: Number(nodesSummary.value.managed_total || nodes.value.filter((item) => Boolean(item.is_managed)).length || 0),
enabled: Number(nodesSummary.value.managed_enabled || nodes.value.filter((item) => Boolean(item.is_enabled)).length || 0),
online: Number(nodesSummary.value.online || 0),
stale: Number(nodesSummary.value.stale || 0),
pendingBootstrap: Number(nodesSummary.value.pending_bootstrap || 0),
participating: Number(nodesSummary.value.participating_nodes || nodes.value.filter((item) => Boolean(item.detect_participating)).length || 0),
standby: Number(nodesSummary.value.non_participating_nodes || 0),
queueDeadLetterNodes: Number(nodesSummary.value.queue_dead_letter_nodes || 0),
}));
const resetManagedNodeForm = () => {
managedNodeForm.value = {
node_code: "",
title: "",
region: "mainland",
role: "worker",
ssh_entry: "",
ssh_host: "",
ssh_port: 22,
ssh_user: "root",
auth_mode: "key",
ssh_password: "",
ssh_private_key: "",
ssh_password_configured: false,
ssh_private_key_configured: false,
clear_ssh_password: false,
clear_ssh_private_key: false,
is_enabled: true,
metadata: {},
};
};
const sshEndpoint = (row: GenericRecord) => {
const user = String(row?.ssh_user || "-").trim() || "-";
const host = String(row?.ssh_host || "-").trim() || "-";
const port = Number(row?.ssh_port || 22) || 22;
return `${user}@${host}:${port}`;
};
const agentStateType = (state: string) => {
const normalized = String(state || "").trim();
if (normalized === "online" || normalized === "online_busy") return "success";
if (normalized === "stale" || normalized === "pending_bootstrap" || normalized === "runtime_only") return "warning";
if (normalized === "token_expired" || normalized === "token_disabled") return "danger";
return "info";
};
const loadNodes = async () => {
loading.value.refreshing = true;
try {
const response = await opsApi.nodes();
nodes.value = Array.isArray(response.data?.nodes) ? response.data.nodes : [];
nodesSummary.value = response.data?.summary || {};
} catch (error: any) {
ElMessage.error(error?.message || "读取托管节点失败");
} finally {
loading.value.refreshing = false;
}
};
const syncNodesFromCluster = async () => {
loading.value.syncing = true;
try {
const response = await opsApi.syncNodesFromCluster(false);
await loadNodes();
ElMessage.success(response.message || "节点已同步");
} catch (error: any) {
ElMessage.error(error?.message || "同步节点失败");
} finally {
loading.value.syncing = false;
}
};
const openManagedNodeDialog = (row?: GenericRecord) => {
resetManagedNodeForm();
managedNodeEditing.value = Boolean(row?.node_code && row?.is_managed);
if (row?.node_code) {
managedNodeForm.value = {
node_code: String(row.node_code || ""),
title: String(row.title || row.node_code || ""),
region: String(row.region || "mainland"),
role: String(row.role || "worker"),
ssh_entry: "",
ssh_host: String(row.ssh_host || row.cluster_ip || row.agent_ip || row.metadata?.ip || ""),
ssh_port: Number(row.ssh_port || 22) || 22,
ssh_user: String(row.ssh_user || "root"),
auth_mode: String(row.auth_mode || "key"),
ssh_password: "",
ssh_private_key: "",
ssh_password_configured: Boolean(row.ssh_password_configured),
ssh_private_key_configured: Boolean(row.ssh_private_key_configured),
clear_ssh_password: false,
clear_ssh_private_key: false,
is_enabled: Boolean(row.is_managed ? row.is_enabled : true),
metadata: { ...(row.metadata || {}) },
};
}
managedNodeVisible.value = true;
};
const saveManagedNode = async () => {
if (!managedNodeForm.value.node_code.trim()) {
ElMessage.warning("请先填写节点编码");
return;
}
loading.value.saving = true;
try {
const response = await opsApi.upsertNode({
node_code: managedNodeForm.value.node_code.trim(),
title: managedNodeForm.value.title.trim() || managedNodeForm.value.node_code.trim(),
region: managedNodeForm.value.region,
role: managedNodeForm.value.role,
ssh_entry: managedNodeForm.value.ssh_entry.trim(),
ssh_host: managedNodeForm.value.ssh_host.trim(),
ssh_port: managedNodeForm.value.ssh_port,
ssh_user: managedNodeForm.value.ssh_user.trim(),
auth_mode: managedNodeForm.value.auth_mode,
ssh_password: managedNodeForm.value.auth_mode === "password" ? managedNodeForm.value.ssh_password : "",
ssh_private_key: managedNodeForm.value.auth_mode === "key" ? managedNodeForm.value.ssh_private_key : "",
clear_ssh_password: Boolean(managedNodeForm.value.clear_ssh_password),
clear_ssh_private_key: Boolean(managedNodeForm.value.clear_ssh_private_key),
is_enabled: managedNodeForm.value.is_enabled,
metadata: {
...(managedNodeForm.value.metadata || {}),
updated_from: "managed-nodes-view",
},
});
managedNodeVisible.value = false;
await loadNodes();
ElMessage.success(response.message || "托管节点已保存");
} catch (error: any) {
ElMessage.error(error?.message || "保存托管节点失败");
} finally {
loading.value.saving = false;
}
};
const toggleNodeEnabled = async (row: GenericRecord) => {
const nextEnabled = !Boolean(row?.is_enabled);
try {
await ElMessageBox.confirm(
nextEnabled ? `确认启用节点 ${row.node_code} 吗?` : `确认停用节点 ${row.node_code} 吗?`,
nextEnabled ? "启用节点" : "停用节点",
{
type: "warning",
confirmButtonText: nextEnabled ? "确认启用" : "确认停用",
cancelButtonText: "取消",
}
);
} catch {
return;
}
try {
const response = await opsApi.upsertNode({
node_code: String(row.node_code || "").trim(),
is_enabled: nextEnabled,
});
await loadNodes();
ElMessage.success(response.message || (nextEnabled ? "节点已启用" : "节点已停用"));
} catch (error: any) {
ElMessage.error(error?.message || "更新节点状态失败");
}
};
const goOpsCenter = () => {
router.push("/ops-center");
};
onMounted(async () => {
await loadNodes();
});
</script>
<style scoped lang="scss">
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.summary-card {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 8px;
}
.summary-label {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #64748b;
}
.summary-note {
color: #64748b;
font-size: 13px;
}
.node-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.node-cell strong {
color: #0f172a;
}
.muted {
color: #64748b;
font-size: 12px;
}
.table-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.dialog-form {
margin-top: 8px;
}
.dialog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.full-width {
grid-column: 1 / -1;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 960px) {
.summary-grid,
.dialog-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -6168,6 +6168,12 @@
<el-option label="control" value="control" />
</el-select>
</el-form-item>
<el-form-item label="SSH 一行录入" class="full-width">
<el-input
v-model="managedNodeForm.ssh_entry"
placeholder="例如 121.204.244.188 root <password> 或 121.204.244.248:22 root <password>"
/>
</el-form-item>
<el-form-item label="SSH Host">
<el-input v-model="managedNodeForm.ssh_host" placeholder="例如 121.204.244.248" />
</el-form-item>
@@ -6183,6 +6189,38 @@
<el-option label="password" value="password" />
</el-select>
</el-form-item>
<el-form-item v-if="managedNodeForm.auth_mode === 'password'" label="SSH 密码" class="full-width">
<el-input
v-model="managedNodeForm.ssh_password"
type="password"
show-password
placeholder="留空表示保留当前密码"
/>
<div class="summary-note" style="margin-top: 6px">
当前状态:{{ managedNodeForm.ssh_password_configured && !managedNodeForm.clear_ssh_password ? "已保存密码" : "未保存密码" }}
</div>
<el-switch
v-model="managedNodeForm.clear_ssh_password"
active-text="清除已保存密码"
inactive-text="保留当前密码"
/>
</el-form-item>
<el-form-item v-else label="SSH 私钥" class="full-width">
<el-input
v-model="managedNodeForm.ssh_private_key"
type="textarea"
:rows="6"
placeholder="可选:粘贴私钥;留空表示保留当前私钥"
/>
<div class="summary-note" style="margin-top: 6px">
当前状态:{{ managedNodeForm.ssh_private_key_configured && !managedNodeForm.clear_ssh_private_key ? "已保存私钥" : "未保存私钥" }}
</div>
<el-switch
v-model="managedNodeForm.clear_ssh_private_key"
active-text="清除已保存私钥"
inactive-text="保留当前私钥"
/>
</el-form-item>
<el-form-item label="发布渠道">
<el-select v-model="managedNodeForm.deploy_channel">
<el-option label="stable" value="stable" />
@@ -6652,10 +6690,17 @@ const managedNodeForm = ref({
title: "",
region: "mainland",
role: "worker",
ssh_entry: "",
ssh_host: "",
ssh_port: 22,
ssh_user: "root",
auth_mode: "key",
ssh_password: "",
ssh_private_key: "",
ssh_password_configured: false,
ssh_private_key_configured: false,
clear_ssh_password: false,
clear_ssh_private_key: false,
deploy_channel: "stable",
is_enabled: true,
metadata: {} as GenericRecord
@@ -12712,10 +12757,17 @@ const resetManagedNodeForm = () => {
title: "",
region: "mainland",
role: "worker",
ssh_entry: "",
ssh_host: "",
ssh_port: 22,
ssh_user: "root",
auth_mode: "key",
ssh_password: "",
ssh_private_key: "",
ssh_password_configured: false,
ssh_private_key_configured: false,
clear_ssh_password: false,
clear_ssh_private_key: false,
deploy_channel: "stable",
is_enabled: true,
metadata: {}
@@ -13696,10 +13748,17 @@ const openManagedNodeDialog = (
title: String(row.title || row.node_code || ""),
region: String(row.region || "mainland"),
role: String(row.role || "worker"),
ssh_entry: "",
ssh_host: String(row.ssh_host || row.cluster_ip || row.agent_ip || row.metadata?.ip || ""),
ssh_port: Number(row.ssh_port || 22) || 22,
ssh_user: String(row.ssh_user || "root"),
auth_mode: String(row.auth_mode || "key"),
ssh_password: "",
ssh_private_key: "",
ssh_password_configured: Boolean(row.ssh_password_configured),
ssh_private_key_configured: Boolean(row.ssh_private_key_configured),
clear_ssh_password: false,
clear_ssh_private_key: false,
deploy_channel: String(row.deploy_channel || "stable"),
is_enabled: Boolean(row.is_managed ? row.is_enabled : true),
metadata: {
@@ -13866,10 +13925,15 @@ const saveManagedNode = async () => {
title: managedNodeForm.value.title.trim() || managedNodeForm.value.node_code.trim(),
region: managedNodeForm.value.region,
role: managedNodeForm.value.role,
ssh_entry: managedNodeForm.value.ssh_entry.trim(),
ssh_host: managedNodeForm.value.ssh_host.trim(),
ssh_port: managedNodeForm.value.ssh_port,
ssh_user: managedNodeForm.value.ssh_user.trim(),
auth_mode: managedNodeForm.value.auth_mode,
ssh_password: managedNodeForm.value.auth_mode === "password" ? managedNodeForm.value.ssh_password : "",
ssh_private_key: managedNodeForm.value.auth_mode === "key" ? managedNodeForm.value.ssh_private_key : "",
clear_ssh_password: Boolean(managedNodeForm.value.clear_ssh_password),
clear_ssh_private_key: Boolean(managedNodeForm.value.clear_ssh_private_key),
deploy_channel: managedNodeForm.value.deploy_channel,
is_enabled: managedNodeForm.value.is_enabled,
metadata: {
@@ -13885,6 +13949,14 @@ const saveManagedNode = async () => {
ssh_port: managedNodeForm.value.ssh_port,
ssh_user: managedNodeForm.value.ssh_user.trim(),
auth_mode: managedNodeForm.value.auth_mode,
ssh_password_configured:
managedNodeForm.value.auth_mode === "password"
? Boolean(managedNodeForm.value.ssh_password || (managedNodeForm.value.ssh_password_configured && !managedNodeForm.value.clear_ssh_password))
: false,
ssh_private_key_configured:
managedNodeForm.value.auth_mode === "key"
? Boolean(managedNodeForm.value.ssh_private_key || (managedNodeForm.value.ssh_private_key_configured && !managedNodeForm.value.clear_ssh_private_key))
: false,
deploy_channel: managedNodeForm.value.deploy_channel,
is_enabled: managedNodeForm.value.is_enabled,
metadata: { ...(managedNodeForm.value.metadata || {}) },

View File

@@ -750,7 +750,7 @@
<el-input :model-value="formatNodeThreadSummary(nodeThreadDialog.nodeCode)" disabled />
</el-form-item>
<el-form-item label="单独覆盖线程数">
<el-input-number v-model="nodeThreadDialog.threadCount" :min="1" :max="256" />
<el-input-number v-model="nodeThreadDialog.threadCount" :min="1" />
</el-form-item>
<el-alert
:closable="false"

View File

@@ -17,7 +17,7 @@
/>
<el-form-item label="默认检测线程数">
<el-input-number v-model="threadCount" :min="1" :max="256" />
<el-input-number v-model="threadCount" :min="1" />
</el-form-item>
<el-form-item label="节点独立线程覆盖">
@@ -27,7 +27,7 @@
</div>
<div v-for="(item, index) in nodeThreadOverrides" :key="`${item.node_code}-${index}`" class="node-thread-row">
<el-input v-model="item.node_code" placeholder="节点编码,例如 mainland-worker-01" />
<el-input-number v-model="item.thread_count" :min="1" :max="256" />
<el-input-number v-model="item.thread_count" :min="1" />
<el-button type="danger" plain @click="removeNodeThreadOverride(index)">删除</el-button>
</div>
<div class="node-thread-actions">