d
This commit is contained in:
471
domain-web/src/views/ops/ManagedNodesView.vue
Normal file
471
domain-web/src/views/ops/ManagedNodesView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user