feat: 优化集群管理逻辑与状态显示

1. 状态国际化与适配:将 healthy/warning/error/unknown 设为标准状态值,同步更新 ClusterList 与 Dashboard 逻辑。
git statusPayload 结构。
3. 错误提示å HTTP 错误状态码提示。
4. 联调文档:新增《集群描述后端联调指南.md》。
pull/48/head
hnu202326010131 4 months ago
parent 3ae20a5f67
commit b2524d6ce3

@ -0,0 +1,53 @@
# 集群描述字段 (description) 后端联调指南
本指南旨在说明前端 `frontend-vue` 中新增的“集群描述”输入框如何与后端 API 进行对接。
## 1. 前端实现细节
### 数据绑定
- **文件**: `src/app/views/ClusterList.vue`
- **变量名**: `description` (响应式字符串 `ref('')`)
- **HTML 元素**:
```html
<input v-model.trim="description" placeholder="集群描述 (可选)" class="header__search-input" />
```
## 2. 接口通信协议
### 注册集群 (Create Cluster)
- **请求方法**: `POST`
- **请求路径**: `/api/v1/clusters`
- **数据格式**: `application/json`
- **Payload 结构**:
前端在提交注册时,会将描述信息放在 JSON 对象的顶层。
```json
{
"name": "集群名称",
"type": "hadoop",
"node_count": 3,
"health_status": "healthy",
"description": "这是用户填写的集群描述内容", // <--
"namenode_ip": "...",
"nodes": [ ... ]
}
```
### 获取列表 (Get Clusters)
- **请求方法**: `GET`
- **请求路径**: `/api/v1/clusters`
- **后端预期返回**:
后端在返回集群列表时,应包含 `description` 字段。前端目前虽主要展示名称和状态,但该字段已在数据模型中预留。
## 3. 联调验证步骤
1. **前端输入**: 在“注册新集群”表单的“集群描述”框中输入测试文本(例如:`Test Description 123`)。
2. **抓包检查**:
- 打开浏览器开发者工具 (F12) -> Network 标签。
- 点击“提交注册”。
- 检查名为 `clusters``POST` 请求,确认 `Payload` 中包含 `description` 字段且值正确。
3. **数据库校验**: 登录后端数据库,检查 `clusters` 表中对应记录的 `description` 列是否成功存入该字符串。
4. **接口回显**: 调用 `GET /api/v1/clusters`,确认返回的 JSON 数据中包含存入的描述内容。
## 4. 后端注意事项
- **字段类型**: 建议使用 `TEXT``VARCHAR(255)`
- **可选性**: 前端已标记为“可选”,后端应允许该字段为 `NULL` 或空字符串。

@ -27,10 +27,10 @@
<input v-model.number="node_count" type="number" min="1" placeholder="节点总数 (node_count)" class="header__search-input u-mr-1" @input="onCountChange" />
<!-- 健康状态对应 DB 中的 health_status -->
<select v-model="health_status" class="header__search-input">
<option value="running">健康</option>
<option value="warning">警告</option>
<option value="error">异常</option>
<option value="unknown">未知</option>
<option value="healthy">healthy</option>
<option value="warning">warning</option>
<option value="error">error</option>
<option value="unknown">unknown</option>
</select>
</div>
@ -42,7 +42,9 @@
<!-- ResourceManager IP -->
<input v-model.trim="rm_ip" placeholder="RM IP" class="header__search-input u-mr-1" />
<!-- ResourceManager 密码 -->
<input v-model.trim="rm_psw" type="password" placeholder="RM 密码" class="header__search-input" />
<input v-model.trim="rm_psw" type="password" placeholder="RM 密码" class="header__search-input u-mr-1" />
<!-- 集群描述 -->
<input v-model.trim="description" placeholder="集群描述 (可选)" class="header__search-input" />
</div>
<h4 class="u-text-gray-900 u-font-bold u-mb-1 u-mt-2">2. 节点详细配置</h4>
@ -58,9 +60,7 @@
<!-- SSH 用户名对应 DB 中的 ssh_user -->
<input v-model.trim="node.ssh_user" placeholder="SSH 用户 (ssh_user)" class="header__search-input u-mr-1" />
<!-- SSH 密码对应 DB 中的 ssh_password -->
<input v-model.trim="node.ssh_password" type="password" placeholder="SSH 密码 (ssh_password)" class="header__search-input u-mr-1" />
<!-- 备注/描述 (可选) -->
<input v-model.trim="node.description" placeholder="描述 (可选)" class="header__search-input" />
<input v-model.trim="node.ssh_password" type="password" placeholder="SSH 密码 (ssh_password)" class="header__search-input" />
</div>
<div class="u-mt-2">
@ -90,12 +90,12 @@
<td class="dashboard__table-td"><span>{{ c.healthText }}</span></td>
<td class="dashboard__table-td">
<button class="btn u-text-sm" @click.stop="toDashboard(c)">进入详情</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status==='running'" @click.stop="startCluster(c.uuid)">启动集群</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status!=='running'" @click.stop="stopCluster(c.uuid)">关闭集群</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status==='healthy'" @click.stop="startCluster(c.uuid)">启动集群</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status!=='healthy'" @click.stop="stopCluster(c.uuid)">关闭集群</button>
<button class="btn u-text-sm u-ml-1" @click.stop="unregister(c.uuid)">注销集群</button>
<button class="btn u-text-sm u-ml-1" @click.stop="discover(c.uuid)">发现角色</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status==='running'" @click.stop="startClusterNew(c.uuid)">按集群启动</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status!=='running'" @click.stop="stopClusterNew(c.uuid)">按集群停止</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status==='healthy'" @click.stop="startClusterNew(c.uuid)">按集群启动</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status!=='healthy'" @click.stop="stopClusterNew(c.uuid)">按集群停止</button>
<button class="btn u-text-sm u-ml-1" @click.stop="syncHosts(c.uuid)">同步 hosts</button>
</td>
</tr>
@ -133,11 +133,12 @@ const namenode_ip = ref('') // NameNode IP
const namenode_psw = ref('') // NameNode
const rm_ip = ref('') // RM IP
const rm_psw = ref('') // RM
const description = ref('') // (description)
// DB nodes
// 使 ref reactive
const nodes = ref<{hostname:string; ip_address:string; ssh_user:string; ssh_password:string; description:string}[]>([
{ hostname: '', ip_address: '', ssh_user: 'hadoop', ssh_password: '', description: '' }
const nodes = ref<{hostname:string; ip_address:string; ssh_user:string; ssh_password:string}[]>([
{ hostname: '', ip_address: '', ssh_user: 'hadoop', ssh_password: '' }
])
//
const err = ref('') //
@ -154,7 +155,7 @@ watch(node_count, (newVal) => {
if (target > current) {
//
for (let i = current; i < target; i++) {
nodes.value.push({ hostname: '', ip_address: '', ssh_user: 'hadoop', ssh_password: '', description: '' })
nodes.value.push({ hostname: '', ip_address: '', ssh_user: 'hadoop', ssh_password: '' })
}
} else if (target < current) {
// target
@ -178,12 +179,13 @@ function cancelRegister() {
namenode_psw.value = ''
rm_ip.value = ''
rm_psw.value = ''
description.value = ''
// 1
nodes.value = [{ hostname: '', ip_address: '', ssh_user: 'hadoop', ssh_password: '', description: '' }]
nodes.value = [{ hostname: '', ip_address: '', ssh_user: 'hadoop', ssh_password: '' }]
}
//
function healthTextOf(h:string){ return h==='running'?'健康':h==='warning'?'警告':h==='error'?'异常':'未知' }
function healthTextOf(h:string){ return h==='healthy'?'健康':h==='warning'?'警告':h==='error'?'异常':'未知' }
//
// - detail.errors/
// - HTTP
@ -209,17 +211,17 @@ function formatError(e: any, defaultMsg: string = '操作失败'): string {
//
let prefix = ''
switch(s) {
case 400: prefix = '请求参数错误'; break
case 401: prefix = '认证失效,请重新登录'; break
case 403: prefix = '权限不足'; break
case 404: prefix = '请求资源不存在'; break
case 409: prefix = '资源冲突'; break
case 422: prefix = '参数校验失败'; break
case 500: prefix = '服务器内部错误'; break
case 502: prefix = '网关错误 (后端服务不可达)'; break
case 503: prefix = '服务暂时不可用'; break
case 504: prefix = '网关超时'; break
default: prefix = `请求失败 (${s})`
case 400: prefix = '请求无效 (Bad Request),请检查输入参数'; break
case 401: prefix = '认证已过期,请重新登录'; break
case 403: prefix = '权限受限,无法执行该操作'; break
case 404: prefix = '未找到请求的资源 (Not Found)'; break
case 409: prefix = '操作冲突,资源可能已存在'; break
case 422: prefix = '输入验证失败,请核对数据格式'; break
case 500: prefix = '服务器内部错误,请联系管理员或稍后重试'; break
case 502: prefix = '网关错误,后端服务可能未启动'; break
case 503: prefix = '服务暂时不可用,请稍后再试'; break
case 504: prefix = '网关超时,后端响应过慢'; break
default: prefix = `请求异常 (${s})`
}
return detail ? `${prefix}:\n${detail}` : prefix
@ -269,7 +271,7 @@ async function onRegister() {
if (!name.value || !node_count.value) { err.value = '请填写集群基本信息'; return }
// 2
if (!Array.isArray(nodes.value)) {
nodes.value = [{ hostname: '', ip_address: '', ssh_user: 'hadoop', ssh_password: '', description: '' }]
nodes.value = [{ hostname: '', ip_address: '', ssh_user: 'hadoop', ssh_password: '' }]
}
// 3
for (let i = 0; i < nodes.value.length; i++) {
@ -291,12 +293,12 @@ async function onRegister() {
namenode_psw: namenode_psw.value,
rm_ip: rm_ip.value,
rm_psw: rm_psw.value,
description: description.value,
nodes: nodes.value.map(n => ({
hostname: n.hostname,
ip_address: n.ip_address,
ssh_user: n.ssh_user,
ssh_password: n.ssh_password,
description: n.description
ssh_password: n.ssh_password
}))
}
// 5

@ -83,7 +83,7 @@ import CpuChart from '../components/CpuChart.vue'
import MemoryChart from '../components/MemoryChart.vue'
const meta = reactive({ uuid: '未选择', host: '-', ip: '-' })
const auth = useAuthStore()
const nodes = reactive<Array<{ name:string; ip:string; status:'running'|'warning'|'error'; cpu:string; mem:string; updated:string }>>([])
const nodes = reactive<Array<{ name:string; ip:string; status:'healthy'|'warning'|'error'; cpu:string; mem:string; updated:string }>>([])
const updateTime = computed(() => {
const d = new Date()
const y = d.getFullYear()
@ -92,7 +92,7 @@ const updateTime = computed(() => {
return `${y}${m}${day}`
})
const totalCount = computed(() => nodes.length)
const healthyCount = computed(() => nodes.filter(n => n.status==='running').length)
const healthyCount = computed(() => nodes.filter(n => n.status==='healthy').length)
const warningCount = computed(() => nodes.filter(n => n.status==='warning').length)
const errorCount = computed(() => nodes.filter(n => n.status==='error').length)
onMounted(() => {
@ -107,8 +107,8 @@ async function loadNodes(){
nodes.splice(0, nodes.length, ...list.map((x:any)=>({ name:x.name, ip:x.ip, status:x.status, cpu:x.cpu, mem:x.mem, updated:x.updated })))
}catch(e:any){ /* silent */ }
}
function statusText(s:'running'|'warning'|'error'){ return s==='running'?'运行中':s==='warning'?'警告':'异常' }
function statusDotClass(s:'running'|'warning'|'error'){ return s==='running'?'status-dot--running':s==='warning'?'status-dot--warning':'status-dot--error' }
function statusText(s:'healthy'|'warning'|'error'){ return s==='healthy'?'运行中':s==='warning'?'警告':'异常' }
function statusDotClass(s:'healthy'|'warning'|'error'){ return s==='healthy'?'status-dot--healthy':s==='warning'?'status-dot--warning':'status-dot--error' }
async function start(name:string){ try{ await api.post(`/v1/nodes/${encodeURIComponent(name)}/start`, {}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined }); await loadNodes() }catch(e:any){} }
async function stop(name:string){ try{ await api.post(`/v1/nodes/${encodeURIComponent(name)}/stop`, {}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined }); await loadNodes() }catch(e:any){} }
async function remove(name:string){ try{ await api.delete(`/v1/nodes/${encodeURIComponent(name)}`, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined }); await loadNodes() }catch(e:any){} }
@ -127,8 +127,8 @@ async function detail(name:string){ try{ await api.get(`/v1/nodes/${encodeURICom
.text-error{ color:#dc2626 }
.status-indicator{ display:inline-flex; align-items:center; gap:6px }
.status-dot{ width:8px; height:8px; border-radius:50% }
.status-dot--running{ background:#16a34a }
.status-dot--warning{ background:#16a34a }
.status-dot--error{ background:#16a34a }
.status-dot--healthy{ background:#16a34a }
.status-dot--warning{ background:#f59e0b }
.status-dot--error{ background:#dc2626 }
.status-text{ font-size:12px }
</style>

@ -690,7 +690,23 @@ function formatError(e: any, def: string) {
const detail = typeof d?.detail === "string" ? d.detail : "";
const errs = Array.isArray(d?.detail?.errors) ? d.detail.errors : [];
const msgs: string[] = [];
if (s) msgs.push(`HTTP ${s}${st ? " " + st : ""}`);
if (s) {
let prefix = `HTTP ${s}`;
switch (s) {
case 400: prefix = "请求无效 (Bad Request)"; break;
case 401: prefix = "会话已过期,请重新登录"; break;
case 403: prefix = "无权访问该诊断资源"; break;
case 404: prefix = "诊断服务接口未找到"; break;
case 500: prefix = "诊断服务器内部故障"; break;
case 502: prefix = "网关响应异常,诊断后端可能已掉线"; break;
case 503: prefix = "诊断服务目前无法处理请求"; break;
case 504: prefix = "诊断请求处理超时"; break;
default: if (st) prefix += ` ${st}`;
}
msgs.push(prefix);
}
if (detail) msgs.push(detail);
if (errs.length)
msgs.push(
@ -699,8 +715,7 @@ function formatError(e: any, def: string) {
.filter(Boolean)
.join("")
);
if (!msgs.length) msgs.push(r ? def : "网络异常或后端不可用");
if (s === 401) msgs.push("Token 已过期或未登录,请重新登录");
if (!msgs.length) msgs.push(r ? def : "网络连接异常,请检查后端服务状态");
return msgs.join(" | ");
}
</script>

Loading…
Cancel
Save