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

@@ -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>