Files
getDomain/domain-web/src/views/ops/ManagedNodesView.vue
Your Name 7cbde2aa78 d
2026-04-22 14:13:21 +08:00

472 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>