feat(pve): 完善PVE节点功能,实现前端VM操作

- 后端:新增 /pve/hosts/:id/status 节点连接状态检测API
- 后端:新增 /pve/hosts/:id/vms 节点虚拟机列表API
- 前端:PVEHosts页面添加节点在线状态标签、节点名展示、VM列表弹窗
- 前端:MachineDetail页面添加VM状态卡片(CPU/内存/运行时长)
- 前端:启动VM按钮添加确认对话框,启停按钮根据VM状态禁用
- 前端:定时轮询VM状态,操作后自动刷新

[宪宪/K2.6🐾]
This commit is contained in:
openclaw
2026-04-21 02:08:41 +08:00
parent a1bef5dc4a
commit e0f3a34b48
31 changed files with 1431 additions and 514 deletions

View File

@@ -98,6 +98,9 @@ func (h *PVEHandler) Create(c *gin.Context) {
return
}
idPtr := host.ID
middleware.LogOperation("create", "pve_host", &idPtr, host.Name, "", host.Hostname+":"+strconv.Itoa(host.Port), c.ClientIP(), middleware.CurrentUser(c))
host.PasswordEnc = ""
host.Password = ""
c.JSON(http.StatusCreated, host)
@@ -139,6 +142,8 @@ func (h *PVEHandler) Update(c *gin.Context) {
return
}
middleware.LogOperation("update", "pve_host", &id, host.Name, "", host.Hostname+":"+strconv.Itoa(host.Port), c.ClientIP(), middleware.CurrentUser(c))
host.PasswordEnc = ""
c.JSON(http.StatusOK, host)
}
@@ -156,14 +161,67 @@ func (h *PVEHandler) Delete(c *gin.Context) {
return
}
// 获取主机名用于日志
host, _ := h.service.GetByID(id)
hostName := ""
if host != nil {
hostName = host.Name
}
if err := h.service.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
middleware.LogOperation("delete", "pve_host", &id, hostName, "", "", c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// HostStatus 检测 PVE 节点连接状态
func (h *PVEHandler) HostStatus(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
status, err := h.service.GetHostStatus(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{"status": "offline", "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": status})
}
// HostVMList 获取 PVE 节点上的虚拟机列表
func (h *PVEHandler) HostVMList(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
vms, err := h.service.GetHostVMList(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, vms)
}
// VMStatus 获取虚拟机状态
func (h *PVEHandler) VMStatus(c *gin.Context) {
if !middleware.IsAdmin(c) {

View File

@@ -101,6 +101,8 @@ func main() {
admin.POST("/pve/hosts", pveH.Create)
admin.PUT("/pve/hosts/:id", pveH.Update)
admin.DELETE("/pve/hosts/:id", pveH.Delete)
admin.GET("/pve/hosts/:id/status", pveH.HostStatus)
admin.GET("/pve/hosts/:id/vms", pveH.HostVMList)
// 虚拟机操作
admin.GET("/machines/:id/vm-status", pveH.VMStatus)

View File

@@ -369,6 +369,45 @@ func (s *PVEHostService) GetDecryptedPassword(host *models.PVEHost) (string, err
return utils.Decrypt(host.PasswordEnc)
}
// GetHostStatus 检测 PVE 节点连接状态
func (s *PVEHostService) GetHostStatus(hostID int64) (string, error) {
host, err := s.GetByID(hostID)
if err != nil {
return "offline", err
}
password, err := s.GetDecryptedPassword(host)
if err != nil {
return "offline", err
}
client := NewPVEClient(host, password)
if err := client.Login(); err != nil {
return "offline", nil
}
return "online", nil
}
// GetHostVMList 获取 PVE 节点上的虚拟机列表
func (s *PVEHostService) GetHostVMList(hostID int64) ([]struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
}, error) {
host, err := s.GetByID(hostID)
if err != nil {
return nil, err
}
password, err := s.GetDecryptedPassword(host)
if err != nil {
return nil, err
}
client := NewPVEClient(host, password)
return client.GetVMList()
}
// GetVMStatus 获取虚拟机状态
func (s *PVEHostService) GetVMStatus(hostID int64, vmid string) (*models.PVEVMStatus, error) {
host, err := s.GetByID(hostID)

View File

@@ -1 +1 @@
import{o as b,c as x,a as o,b as s,w as l,r as g,d as u,u as k,e as v,f as N,g as w,l as z,h as y,E as d,i as C,s as V,j as M}from"./index-lpjSKhQ-.js";import{_ as A}from"./_plugin-vue_export-helper-DlAUqK2U.js";const B={class:"login-page"},E={class:"login-card"},K={class:"input-wrap"},U={class:"input-wrap"},$={class:"login-guest"},j={__name:"Login",setup(I){const c=k(),a=g({username:"",password:""}),n=g(!1);async function r(){if(!a.value.username||!a.value.password){d.warning("请输入用户名和密码");return}n.value=!0;try{const t=await C(a.value);V(t.data),d.success("登录成功"),c.push("/")}catch{}n.value=!1}async function h(){n.value=!0;try{const t=await M();V(t.data),d.success("已进入访客模式"),c.push("/")}catch{}n.value=!1}return(t,e)=>{const p=u("el-icon"),_=u("el-input"),m=u("el-form-item"),f=u("el-button"),L=u("el-form");return b(),x("div",B,[o("div",E,[e[4]||(e[4]=o("div",{class:"login-header"},[o("div",{class:"logo"},"LM"),o("h1",null,"LAN Manager"),o("p",null,"轻量级局域网资产管理平台")],-1)),s(L,{model:a.value,class:"login-form"},{default:l(()=>[s(m,null,{default:l(()=>[o("div",K,[s(p,{class:"input-icon"},{default:l(()=>[s(v(N))]),_:1}),s(_,{modelValue:a.value.username,"onUpdate:modelValue":e[0]||(e[0]=i=>a.value.username=i),placeholder:"用户名",size:"large",onKeydown:w(r,["enter"])},null,8,["modelValue"])])]),_:1}),s(m,null,{default:l(()=>[o("div",U,[s(p,{class:"input-icon"},{default:l(()=>[s(v(z))]),_:1}),s(_,{modelValue:a.value.password,"onUpdate:modelValue":e[1]||(e[1]=i=>a.value.password=i),type:"password","show-password":"",placeholder:"密码",size:"large",onKeydown:w(r,["enter"])},null,8,["modelValue"])])]),_:1}),s(f,{type:"primary",size:"large",class:"login-btn",loading:n.value,onClick:r},{default:l(()=>[...e[2]||(e[2]=[y("登录",-1)])]),_:1},8,["loading"]),o("div",$,[s(f,{text:"",size:"small",onClick:h},{default:l(()=>[...e[3]||(e[3]=[y("访客模式进入",-1)])]),_:1})])]),_:1},8,["model"])]),e[5]||(e[5]=o("div",{class:"login-footer"}," © LAN Manager ",-1))])}}},q=A(j,[["__scopeId","data-v-92ccba4b"]]);export{q as default};
import{o as b,c as x,a as o,b as s,w as l,r as g,d as u,u as k,e as v,f as N,g as w,l as z,h as y,E as d,i as C,s as V,j as M}from"./index-ybrLS-Uj.js";import{_ as A}from"./_plugin-vue_export-helper-DlAUqK2U.js";const B={class:"login-page"},E={class:"login-card"},K={class:"input-wrap"},U={class:"input-wrap"},$={class:"login-guest"},j={__name:"Login",setup(I){const c=k(),a=g({username:"",password:""}),n=g(!1);async function r(){if(!a.value.username||!a.value.password){d.warning("请输入用户名和密码");return}n.value=!0;try{const t=await C(a.value);V(t.data),d.success("登录成功"),c.push("/")}catch{}n.value=!1}async function h(){n.value=!0;try{const t=await M();V(t.data),d.success("已进入访客模式"),c.push("/")}catch{}n.value=!1}return(t,e)=>{const p=u("el-icon"),_=u("el-input"),m=u("el-form-item"),f=u("el-button"),L=u("el-form");return b(),x("div",B,[o("div",E,[e[4]||(e[4]=o("div",{class:"login-header"},[o("div",{class:"logo"},"LM"),o("h1",null,"LAN Manager"),o("p",null,"轻量级局域网资产管理平台")],-1)),s(L,{model:a.value,class:"login-form"},{default:l(()=>[s(m,null,{default:l(()=>[o("div",K,[s(p,{class:"input-icon"},{default:l(()=>[s(v(N))]),_:1}),s(_,{modelValue:a.value.username,"onUpdate:modelValue":e[0]||(e[0]=i=>a.value.username=i),placeholder:"用户名",size:"large",onKeydown:w(r,["enter"])},null,8,["modelValue"])])]),_:1}),s(m,null,{default:l(()=>[o("div",U,[s(p,{class:"input-icon"},{default:l(()=>[s(v(z))]),_:1}),s(_,{modelValue:a.value.password,"onUpdate:modelValue":e[1]||(e[1]=i=>a.value.password=i),type:"password","show-password":"",placeholder:"密码",size:"large",onKeydown:w(r,["enter"])},null,8,["modelValue"])])]),_:1}),s(f,{type:"primary",size:"large",class:"login-btn",loading:n.value,onClick:r},{default:l(()=>[...e[2]||(e[2]=[y("登录",-1)])]),_:1},8,["loading"]),o("div",$,[s(f,{text:"",size:"small",onClick:h},{default:l(()=>[...e[3]||(e[3]=[y("访客模式进入",-1)])]),_:1})])]),_:1},8,["model"])]),e[5]||(e[5]=o("div",{class:"login-footer"}," © LAN Manager ",-1))])}}},q=A(j,[["__scopeId","data-v-92ccba4b"]]);export{q as default};

View File

@@ -1 +1 @@
import{k as V,ap as $,o as w,c as k,a as o,b as t,w as d,t as f,aq as N,m as B,r as i,d as r,ar as C,h as b}from"./index-lpjSKhQ-.js";import{_ as L}from"./_plugin-vue_export-helper-DlAUqK2U.js";const M={class:"page"},T={class:"page-header"},U={class:"header-actions"},I={class:"card"},q={class:"table-header"},E={class:"pagination-bar"},F={__name:"Logs",setup(H){const h=i([]),p=i(0),c=i(1),u=i(20),_=i(""),g=i(!1);V(()=>v());async function v(){g.value=!0;const l=await $({page:c.value,page_size:u.value,search:_.value});h.value=l.data.list,p.value=l.data.total,g.value=!1}function y(l){if(!l)return"-";const e=new Date(l);if(isNaN(e.getTime()))return l;const s=m=>String(m).padStart(2,"0");return`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())} ${s(e.getHours())}:${s(e.getMinutes())}:${s(e.getSeconds())}`}return(l,e)=>{const s=r("el-input"),m=r("el-button"),n=r("el-table-column"),z=r("el-tag"),x=r("el-table"),D=r("el-pagination"),S=C("loading");return w(),k("div",M,[o("div",T,[e[4]||(e[4]=o("div",null,[o("div",{class:"page-title"},"操作日志"),o("div",{class:"page-subtitle"},"查看最近的系统操作记录")],-1)),o("div",U,[t(s,{modelValue:_.value,"onUpdate:modelValue":e[0]||(e[0]=a=>_.value=a),placeholder:"搜索操作内容",clearable:"",style:{width:"240px"}},null,8,["modelValue"]),t(m,{type:"primary",onClick:v},{default:d(()=>[...e[3]||(e[3]=[b("查询",-1)])]),_:1})])]),o("div",I,[o("div",q,"共 "+f(p.value)+" 条记录",1),N((w(),B(x,{data:h.value,stripe:"",style:{width:"100%"},class:"modern-table"},{default:d(()=>[t(n,{prop:"id",label:"ID",width:"70"}),t(n,{prop:"action",label:"操作",width:"140"}),t(n,{prop:"target_type",label:"对象",width:"100"},{default:d(({row:a})=>[t(z,{size:"small",effect:"plain",round:""},{default:d(()=>[b(f(a.target_type),1)]),_:2},1024)]),_:1}),t(n,{prop:"target_name",label:"对象名称","min-width":"160"}),t(n,{prop:"details",label:"详情","min-width":"240","show-overflow-tooltip":""}),t(n,{prop:"created_at",label:"时间",width:"170"},{default:d(({row:a})=>[b(f(y(a.created_at)),1)]),_:1})]),_:1},8,["data"])),[[S,g.value]]),o("div",E,[t(D,{"current-page":c.value,"onUpdate:currentPage":e[1]||(e[1]=a=>c.value=a),"page-size":u.value,"onUpdate:pageSize":e[2]||(e[2]=a=>u.value=a),"page-sizes":[10,20,50,100],layout:"total, sizes, prev, pager, next",total:p.value,onChange:v},null,8,["current-page","page-size","total"])])])])}}},j=L(F,[["__scopeId","data-v-b1b09e4f"]]);export{j as default};
import{k as V,aq as $,o as w,c as k,a as o,b as t,w as d,t as f,ar as N,m as B,r as i,d as r,as as C,h as b}from"./index-ybrLS-Uj.js";import{_ as L}from"./_plugin-vue_export-helper-DlAUqK2U.js";const M={class:"page"},T={class:"page-header"},U={class:"header-actions"},I={class:"card"},q={class:"table-header"},E={class:"pagination-bar"},F={__name:"Logs",setup(H){const h=i([]),p=i(0),c=i(1),u=i(20),_=i(""),g=i(!1);V(()=>v());async function v(){g.value=!0;const l=await $({page:c.value,page_size:u.value,search:_.value});h.value=l.data.list,p.value=l.data.total,g.value=!1}function y(l){if(!l)return"-";const e=new Date(l);if(isNaN(e.getTime()))return l;const s=m=>String(m).padStart(2,"0");return`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())} ${s(e.getHours())}:${s(e.getMinutes())}:${s(e.getSeconds())}`}return(l,e)=>{const s=r("el-input"),m=r("el-button"),n=r("el-table-column"),z=r("el-tag"),x=r("el-table"),D=r("el-pagination"),S=C("loading");return w(),k("div",M,[o("div",T,[e[4]||(e[4]=o("div",null,[o("div",{class:"page-title"},"操作日志"),o("div",{class:"page-subtitle"},"查看最近的系统操作记录")],-1)),o("div",U,[t(s,{modelValue:_.value,"onUpdate:modelValue":e[0]||(e[0]=a=>_.value=a),placeholder:"搜索操作内容",clearable:"",style:{width:"240px"}},null,8,["modelValue"]),t(m,{type:"primary",onClick:v},{default:d(()=>[...e[3]||(e[3]=[b("查询",-1)])]),_:1})])]),o("div",I,[o("div",q,"共 "+f(p.value)+" 条记录",1),N((w(),B(x,{data:h.value,stripe:"",style:{width:"100%"},class:"modern-table"},{default:d(()=>[t(n,{prop:"id",label:"ID",width:"70"}),t(n,{prop:"action",label:"操作",width:"140"}),t(n,{prop:"target_type",label:"对象",width:"100"},{default:d(({row:a})=>[t(z,{size:"small",effect:"plain",round:""},{default:d(()=>[b(f(a.target_type),1)]),_:2},1024)]),_:1}),t(n,{prop:"target_name",label:"对象名称","min-width":"160"}),t(n,{prop:"details",label:"详情","min-width":"240","show-overflow-tooltip":""}),t(n,{prop:"created_at",label:"时间",width:"170"},{default:d(({row:a})=>[b(f(y(a.created_at)),1)]),_:1})]),_:1},8,["data"])),[[S,g.value]]),o("div",E,[t(D,{"current-page":c.value,"onUpdate:currentPage":e[1]||(e[1]=a=>c.value=a),"page-size":u.value,"onUpdate:pageSize":e[2]||(e[2]=a=>u.value=a),"page-sizes":[10,20,50,100],layout:"total, sizes, prev, pager, next",total:p.value,onChange:v},null,8,["current-page","page-size","total"])])])])}}},j=L(F,[["__scopeId","data-v-b1b09e4f"]]);export{j as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{k as h,o as i,c as m,a as s,b as e,w as t,m as d,n as r,t as v,p as C,r as x,q as w,d as _,u as E,e as l,v as L,x as M,y as A,z as B,A as D,B as N,C as V,f as z,D as I,F as S,E as q,G as F,H as G}from"./index-lpjSKhQ-.js";import{_ as H}from"./_plugin-vue_export-helper-DlAUqK2U.js";const P={class:"layout"},R={key:0,class:"sidebar"},T={class:"nav"},$={class:"sidebar-footer"},j={class:"user-info"},J={class:"user-name"},K={class:"main-inner"},O={__name:"MainLayout",setup(Q){const f=E(),u=w(()=>F().is_admin),o=x(!1);h(()=>{o.value=document.documentElement.classList.contains("dark")});function p(){o.value=!o.value,document.documentElement.classList.toggle("dark",o.value),localStorage.setItem("theme",o.value?"dark":"light")}async function g(){try{await S()}catch{}G(),q.success("已退出"),f.push("/login")}return(k,a)=>{const n=_("el-icon"),c=_("router-link"),y=_("el-button"),b=_("router-view");return i(),m("div",P,[u.value?(i(),m("aside",R,[a[5]||(a[5]=s("div",{class:"brand"},[s("div",{class:"brand-logo"},"LM"),s("div",{class:"brand-text"},"LAN Manager")],-1)),s("nav",T,[e(c,{to:"/machines",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(L))]),_:1}),a[0]||(a[0]=s("span",null,"机器列表",-1))]),_:1}),u.value?(i(),d(c,{key:0,to:"/topology",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(M))]),_:1}),a[1]||(a[1]=s("span",null,"拓扑图",-1))]),_:1})):r("",!0),u.value?(i(),d(c,{key:1,to:"/logs",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(A))]),_:1}),a[2]||(a[2]=s("span",null,"操作日志",-1))]),_:1})):r("",!0),u.value?(i(),d(c,{key:2,to:"/pve-hosts",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(B))]),_:1}),a[3]||(a[3]=s("span",null,"PVE 主机",-1))]),_:1})):r("",!0)]),s("div",{class:"theme-toggle",onClick:p},[e(n,{class:"theme-icon"},{default:t(()=>[(i(),d(D(o.value?l(N):l(V))))]),_:1}),s("span",null,v(o.value?"浅色模式":"深色模式"),1)]),s("div",$,[s("div",j,[e(n,{class:"user-icon"},{default:t(()=>[e(l(z))]),_:1}),s("span",J,v(u.value?"管理员":"访客"),1)]),e(y,{text:"",class:"logout-btn",onClick:g},{default:t(()=>[e(n,null,{default:t(()=>[e(l(I))]),_:1}),a[4]||(a[4]=s("span",null,"退出",-1))]),_:1})])])):r("",!0),s("main",{class:C(["main",{"no-sidebar":!u.value}])},[s("div",K,[e(b)])],2)])}}},X=H(O,[["__scopeId","data-v-dac2493b"]]);export{X as default};
import{k as h,o as i,c as m,a as s,b as e,w as t,m as d,n as r,t as v,p as C,r as x,q as w,d as _,u as E,e as l,v as L,x as M,y as A,z as B,A as D,B as N,C as V,f as z,D as I,F as S,E as q,G as F,H as G}from"./index-ybrLS-Uj.js";import{_ as H}from"./_plugin-vue_export-helper-DlAUqK2U.js";const P={class:"layout"},R={key:0,class:"sidebar"},T={class:"nav"},$={class:"sidebar-footer"},j={class:"user-info"},J={class:"user-name"},K={class:"main-inner"},O={__name:"MainLayout",setup(Q){const f=E(),u=w(()=>F().is_admin),o=x(!1);h(()=>{o.value=document.documentElement.classList.contains("dark")});function p(){o.value=!o.value,document.documentElement.classList.toggle("dark",o.value),localStorage.setItem("theme",o.value?"dark":"light")}async function g(){try{await S()}catch{}G(),q.success("已退出"),f.push("/login")}return(k,a)=>{const n=_("el-icon"),c=_("router-link"),y=_("el-button"),b=_("router-view");return i(),m("div",P,[u.value?(i(),m("aside",R,[a[5]||(a[5]=s("div",{class:"brand"},[s("div",{class:"brand-logo"},"LM"),s("div",{class:"brand-text"},"LAN Manager")],-1)),s("nav",T,[e(c,{to:"/machines",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(L))]),_:1}),a[0]||(a[0]=s("span",null,"机器列表",-1))]),_:1}),u.value?(i(),d(c,{key:0,to:"/topology",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(M))]),_:1}),a[1]||(a[1]=s("span",null,"拓扑图",-1))]),_:1})):r("",!0),u.value?(i(),d(c,{key:1,to:"/logs",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(A))]),_:1}),a[2]||(a[2]=s("span",null,"操作日志",-1))]),_:1})):r("",!0),u.value?(i(),d(c,{key:2,to:"/pve-hosts",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(B))]),_:1}),a[3]||(a[3]=s("span",null,"PVE 主机",-1))]),_:1})):r("",!0)]),s("div",{class:"theme-toggle",onClick:p},[e(n,{class:"theme-icon"},{default:t(()=>[(i(),d(D(o.value?l(N):l(V))))]),_:1}),s("span",null,v(o.value?"浅色模式":"深色模式"),1)]),s("div",$,[s("div",j,[e(n,{class:"user-icon"},{default:t(()=>[e(l(z))]),_:1}),s("span",J,v(u.value?"管理员":"访客"),1)]),e(y,{text:"",class:"logout-btn",onClick:g},{default:t(()=>[e(n,null,{default:t(()=>[e(l(I))]),_:1}),a[4]||(a[4]=s("span",null,"退出",-1))]),_:1})])])):r("",!0),s("main",{class:C(["main",{"no-sidebar":!u.value}])},[s("div",K,[e(b)])],2)])}}},X=H(O,[["__scopeId","data-v-dac2493b"]]);export{X as default};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.toolbar[data-v-b00c62c0]{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.page-title[data-v-b00c62c0]{margin:0;font-size:20px;font-weight:600}.hosts-grid[data-v-b00c62c0]{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}.host-card[data-v-b00c62c0]{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}.host-header[data-v-b00c62c0]{display:flex;align-items:center;gap:10px;margin-bottom:12px}.host-icon[data-v-b00c62c0]{font-size:20px;color:var(--el-color-primary)}.host-name[data-v-b00c62c0]{font-size:16px;font-weight:600;flex:1}.status-tag[data-v-b00c62c0]{font-size:11px;height:20px;padding:0 8px}.host-info[data-v-b00c62c0]{margin-bottom:12px}.info-row[data-v-b00c62c0]{display:flex;font-size:13px;margin-bottom:4px}.info-row .label[data-v-b00c62c0]{color:var(--text-secondary);width:50px}.info-row .value[data-v-b00c62c0]{color:var(--text)}.host-actions[data-v-b00c62c0]{display:flex;gap:8px}.vm-loading[data-v-b00c62c0]{display:flex;align-items:center;justify-content:center;gap:10px;padding:40px 0;color:var(--text-secondary)}.loading-icon[data-v-b00c62c0]{font-size:20px;animation:spin-b00c62c0 1s linear infinite}@keyframes spin-b00c62c0{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.vm-list[data-v-b00c62c0]{display:flex;flex-direction:column;gap:8px}.vm-item[data-v-b00c62c0]{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--surface-hover);border-radius:8px}.vm-main[data-v-b00c62c0]{display:flex;align-items:center;gap:10px}.vm-id[data-v-b00c62c0]{font-size:12px;font-weight:700;color:var(--el-color-primary);background:var(--surface);padding:2px 8px;border-radius:4px;border:1px solid var(--border)}.vm-name[data-v-b00c62c0]{font-size:14px;font-weight:500;color:var(--text)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>局域网机器管理后台</title>
<script type="module" crossorigin src="/assets/index-lpjSKhQ-.js"></script>
<script type="module" crossorigin src="/assets/index-ybrLS-Uj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHYRt9JF.css">
</head>
<body>

1
web/dist/assets/Login-T4gCuUld.js vendored Normal file
View File

@@ -0,0 +1 @@
import{o as b,c as x,a as o,b as s,w as l,r as g,d as u,u as k,e as v,f as N,g as w,l as z,h as y,E as d,i as C,s as V,j as M}from"./index-ybrLS-Uj.js";import{_ as A}from"./_plugin-vue_export-helper-DlAUqK2U.js";const B={class:"login-page"},E={class:"login-card"},K={class:"input-wrap"},U={class:"input-wrap"},$={class:"login-guest"},j={__name:"Login",setup(I){const c=k(),a=g({username:"",password:""}),n=g(!1);async function r(){if(!a.value.username||!a.value.password){d.warning("请输入用户名和密码");return}n.value=!0;try{const t=await C(a.value);V(t.data),d.success("登录成功"),c.push("/")}catch{}n.value=!1}async function h(){n.value=!0;try{const t=await M();V(t.data),d.success("已进入访客模式"),c.push("/")}catch{}n.value=!1}return(t,e)=>{const p=u("el-icon"),_=u("el-input"),m=u("el-form-item"),f=u("el-button"),L=u("el-form");return b(),x("div",B,[o("div",E,[e[4]||(e[4]=o("div",{class:"login-header"},[o("div",{class:"logo"},"LM"),o("h1",null,"LAN Manager"),o("p",null,"轻量级局域网资产管理平台")],-1)),s(L,{model:a.value,class:"login-form"},{default:l(()=>[s(m,null,{default:l(()=>[o("div",K,[s(p,{class:"input-icon"},{default:l(()=>[s(v(N))]),_:1}),s(_,{modelValue:a.value.username,"onUpdate:modelValue":e[0]||(e[0]=i=>a.value.username=i),placeholder:"用户名",size:"large",onKeydown:w(r,["enter"])},null,8,["modelValue"])])]),_:1}),s(m,null,{default:l(()=>[o("div",U,[s(p,{class:"input-icon"},{default:l(()=>[s(v(z))]),_:1}),s(_,{modelValue:a.value.password,"onUpdate:modelValue":e[1]||(e[1]=i=>a.value.password=i),type:"password","show-password":"",placeholder:"密码",size:"large",onKeydown:w(r,["enter"])},null,8,["modelValue"])])]),_:1}),s(f,{type:"primary",size:"large",class:"login-btn",loading:n.value,onClick:r},{default:l(()=>[...e[2]||(e[2]=[y("登录",-1)])]),_:1},8,["loading"]),o("div",$,[s(f,{text:"",size:"small",onClick:h},{default:l(()=>[...e[3]||(e[3]=[y("访客模式进入",-1)])]),_:1})])]),_:1},8,["model"])]),e[5]||(e[5]=o("div",{class:"login-footer"}," © LAN Manager ",-1))])}}},q=A(j,[["__scopeId","data-v-92ccba4b"]]);export{q as default};

1
web/dist/assets/Logs-BBHuDNkg.js vendored Normal file
View File

@@ -0,0 +1 @@
import{k as V,aq as $,o as w,c as k,a as o,b as t,w as d,t as f,ar as N,m as B,r as i,d as r,as as C,h as b}from"./index-ybrLS-Uj.js";import{_ as L}from"./_plugin-vue_export-helper-DlAUqK2U.js";const M={class:"page"},T={class:"page-header"},U={class:"header-actions"},I={class:"card"},q={class:"table-header"},E={class:"pagination-bar"},F={__name:"Logs",setup(H){const h=i([]),p=i(0),c=i(1),u=i(20),_=i(""),g=i(!1);V(()=>v());async function v(){g.value=!0;const l=await $({page:c.value,page_size:u.value,search:_.value});h.value=l.data.list,p.value=l.data.total,g.value=!1}function y(l){if(!l)return"-";const e=new Date(l);if(isNaN(e.getTime()))return l;const s=m=>String(m).padStart(2,"0");return`${e.getFullYear()}-${s(e.getMonth()+1)}-${s(e.getDate())} ${s(e.getHours())}:${s(e.getMinutes())}:${s(e.getSeconds())}`}return(l,e)=>{const s=r("el-input"),m=r("el-button"),n=r("el-table-column"),z=r("el-tag"),x=r("el-table"),D=r("el-pagination"),S=C("loading");return w(),k("div",M,[o("div",T,[e[4]||(e[4]=o("div",null,[o("div",{class:"page-title"},"操作日志"),o("div",{class:"page-subtitle"},"查看最近的系统操作记录")],-1)),o("div",U,[t(s,{modelValue:_.value,"onUpdate:modelValue":e[0]||(e[0]=a=>_.value=a),placeholder:"搜索操作内容",clearable:"",style:{width:"240px"}},null,8,["modelValue"]),t(m,{type:"primary",onClick:v},{default:d(()=>[...e[3]||(e[3]=[b("查询",-1)])]),_:1})])]),o("div",I,[o("div",q,"共 "+f(p.value)+" 条记录",1),N((w(),B(x,{data:h.value,stripe:"",style:{width:"100%"},class:"modern-table"},{default:d(()=>[t(n,{prop:"id",label:"ID",width:"70"}),t(n,{prop:"action",label:"操作",width:"140"}),t(n,{prop:"target_type",label:"对象",width:"100"},{default:d(({row:a})=>[t(z,{size:"small",effect:"plain",round:""},{default:d(()=>[b(f(a.target_type),1)]),_:2},1024)]),_:1}),t(n,{prop:"target_name",label:"对象名称","min-width":"160"}),t(n,{prop:"details",label:"详情","min-width":"240","show-overflow-tooltip":""}),t(n,{prop:"created_at",label:"时间",width:"170"},{default:d(({row:a})=>[b(f(y(a.created_at)),1)]),_:1})]),_:1},8,["data"])),[[S,g.value]]),o("div",E,[t(D,{"current-page":c.value,"onUpdate:currentPage":e[1]||(e[1]=a=>c.value=a),"page-size":u.value,"onUpdate:pageSize":e[2]||(e[2]=a=>u.value=a),"page-sizes":[10,20,50,100],layout:"total, sizes, prev, pager, next",total:p.value,onChange:v},null,8,["current-page","page-size","total"])])])])}}},j=L(F,[["__scopeId","data-v-b1b09e4f"]]);export{j as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{k as h,o as i,c as m,a as s,b as e,w as t,m as d,n as r,t as v,p as C,r as x,q as w,d as _,u as E,e as l,v as L,x as M,y as A,z as B,A as D,B as N,C as V,f as z,D as I,F as S,E as q,G as F,H as G}from"./index-ybrLS-Uj.js";import{_ as H}from"./_plugin-vue_export-helper-DlAUqK2U.js";const P={class:"layout"},R={key:0,class:"sidebar"},T={class:"nav"},$={class:"sidebar-footer"},j={class:"user-info"},J={class:"user-name"},K={class:"main-inner"},O={__name:"MainLayout",setup(Q){const f=E(),u=w(()=>F().is_admin),o=x(!1);h(()=>{o.value=document.documentElement.classList.contains("dark")});function p(){o.value=!o.value,document.documentElement.classList.toggle("dark",o.value),localStorage.setItem("theme",o.value?"dark":"light")}async function g(){try{await S()}catch{}G(),q.success("已退出"),f.push("/login")}return(k,a)=>{const n=_("el-icon"),c=_("router-link"),y=_("el-button"),b=_("router-view");return i(),m("div",P,[u.value?(i(),m("aside",R,[a[5]||(a[5]=s("div",{class:"brand"},[s("div",{class:"brand-logo"},"LM"),s("div",{class:"brand-text"},"LAN Manager")],-1)),s("nav",T,[e(c,{to:"/machines",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(L))]),_:1}),a[0]||(a[0]=s("span",null,"机器列表",-1))]),_:1}),u.value?(i(),d(c,{key:0,to:"/topology",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(M))]),_:1}),a[1]||(a[1]=s("span",null,"拓扑图",-1))]),_:1})):r("",!0),u.value?(i(),d(c,{key:1,to:"/logs",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(A))]),_:1}),a[2]||(a[2]=s("span",null,"操作日志",-1))]),_:1})):r("",!0),u.value?(i(),d(c,{key:2,to:"/pve-hosts",class:"nav-item","active-class":"active"},{default:t(()=>[e(n,null,{default:t(()=>[e(l(B))]),_:1}),a[3]||(a[3]=s("span",null,"PVE 主机",-1))]),_:1})):r("",!0)]),s("div",{class:"theme-toggle",onClick:p},[e(n,{class:"theme-icon"},{default:t(()=>[(i(),d(D(o.value?l(N):l(V))))]),_:1}),s("span",null,v(o.value?"浅色模式":"深色模式"),1)]),s("div",$,[s("div",j,[e(n,{class:"user-icon"},{default:t(()=>[e(l(z))]),_:1}),s("span",J,v(u.value?"管理员":"访客"),1)]),e(y,{text:"",class:"logout-btn",onClick:g},{default:t(()=>[e(n,null,{default:t(()=>[e(l(I))]),_:1}),a[4]||(a[4]=s("span",null,"退出",-1))]),_:1})])])):r("",!0),s("main",{class:C(["main",{"no-sidebar":!u.value}])},[s("div",K,[e(b)])],2)])}}},X=H(O,[["__scopeId","data-v-dac2493b"]]);export{X as default};

1
web/dist/assets/PVEHosts-CEZ8F_J3.js vendored Normal file

File diff suppressed because one or more lines are too long

1
web/dist/assets/PVEHosts-D2p3oOfw.css vendored Normal file
View File

@@ -0,0 +1 @@
.toolbar[data-v-b00c62c0]{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.page-title[data-v-b00c62c0]{margin:0;font-size:20px;font-weight:600}.hosts-grid[data-v-b00c62c0]{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}.host-card[data-v-b00c62c0]{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}.host-header[data-v-b00c62c0]{display:flex;align-items:center;gap:10px;margin-bottom:12px}.host-icon[data-v-b00c62c0]{font-size:20px;color:var(--el-color-primary)}.host-name[data-v-b00c62c0]{font-size:16px;font-weight:600;flex:1}.status-tag[data-v-b00c62c0]{font-size:11px;height:20px;padding:0 8px}.host-info[data-v-b00c62c0]{margin-bottom:12px}.info-row[data-v-b00c62c0]{display:flex;font-size:13px;margin-bottom:4px}.info-row .label[data-v-b00c62c0]{color:var(--text-secondary);width:50px}.info-row .value[data-v-b00c62c0]{color:var(--text)}.host-actions[data-v-b00c62c0]{display:flex;gap:8px}.vm-loading[data-v-b00c62c0]{display:flex;align-items:center;justify-content:center;gap:10px;padding:40px 0;color:var(--text-secondary)}.loading-icon[data-v-b00c62c0]{font-size:20px;animation:spin-b00c62c0 1s linear infinite}@keyframes spin-b00c62c0{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.vm-list[data-v-b00c62c0]{display:flex;flex-direction:column;gap:8px}.vm-item[data-v-b00c62c0]{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--surface-hover);border-radius:8px}.vm-main[data-v-b00c62c0]{display:flex;align-items:center;gap:10px}.vm-id[data-v-b00c62c0]{font-size:12px;font-weight:700;color:var(--el-color-primary);background:var(--surface);padding:2px 8px;border-radius:4px;border:1px solid var(--border)}.vm-name[data-v-b00c62c0]{font-size:14px;font-weight:500;color:var(--text)}

View File

@@ -1 +0,0 @@
import{k as N,Q as $,o as p,c as f,a as t,b as o,w as s,e as g,M,m as S,n as k,N as z,O as D,r as y,d,h as v,v as L,t as c,a8 as F,as as I,E as V,at as O,au as Q}from"./index-lpjSKhQ-.js";import{_ as T}from"./_plugin-vue_export-helper-DlAUqK2U.js";const j={class:"page"},A={class:"toolbar"},G={key:1,class:"hosts-grid"},J={class:"host-header"},K={class:"host-name"},R={class:"host-info"},W={class:"info-row"},X={class:"value"},Y={class:"info-row"},Z={class:"value"},ee={class:"host-actions"},le={__name:"PVEHosts",setup(ae){const _=y([]),i=y(!1),l=y({name:"",hostname:"",port:8006,node_name:"pve",username:"",password:"",verify_ssl:!1});N(()=>{w()});async function w(){const n=await $();_.value=n.data}function b(n){l.value=n?{...n,password:""}:{name:"",hostname:"",port:8006,node_name:"pve",username:"",password:"",verify_ssl:!1},i.value=!0}async function E(){if(!l.value.name||!l.value.hostname||!l.value.username){V.warning("请填写必填项");return}if(!l.value.id&&!l.value.password){V.warning("请填写密码");return}try{l.value.id?await O(l.value.id,l.value):await Q(l.value),V.success("保存成功"),i.value=!1,w()}catch{}}async function C(n){try{await F.confirm(`确定要删除主机 "${n.name}" 吗?`,"确认删除",{type:"warning"}),await I(n.id),V.success("删除成功"),w()}catch{}}return(n,e)=>{const r=d("el-button"),x=d("el-empty"),P=d("el-icon"),m=d("el-input"),u=d("el-form-item"),U=d("el-input-number"),H=d("el-switch"),h=d("el-form"),q=d("el-dialog");return p(),f("div",j,[t("div",A,[e[11]||(e[11]=t("h2",{class:"page-title"},"PVE 主机管理",-1)),o(r,{type:"primary",icon:g(M),onClick:e[0]||(e[0]=a=>b())},{default:s(()=>[...e[10]||(e[10]=[v("添加主机",-1)])]),_:1},8,["icon"])]),_.value.length?k("",!0):(p(),S(x,{key:0,description:"暂无 PVE 主机"})),_.value.length?(p(),f("div",G,[(p(!0),f(z,null,D(_.value,a=>(p(),f("div",{key:a.id,class:"host-card"},[t("div",J,[o(P,{class:"host-icon"},{default:s(()=>[o(g(L))]),_:1}),t("span",K,c(a.name),1)]),t("div",R,[t("div",W,[e[12]||(e[12]=t("span",{class:"label"},"地址:",-1)),t("span",X,c(a.hostname)+":"+c(a.port),1)]),t("div",Y,[e[13]||(e[13]=t("span",{class:"label"},"用户:",-1)),t("span",Z,c(a.username),1)])]),t("div",ee,[o(r,{size:"small",onClick:B=>b(a)},{default:s(()=>[...e[14]||(e[14]=[v("编辑",-1)])]),_:1},8,["onClick"]),o(r,{size:"small",type:"danger",onClick:B=>C(a)},{default:s(()=>[...e[15]||(e[15]=[v("删除",-1)])]),_:1},8,["onClick"])])]))),128))])):k("",!0),o(q,{modelValue:i.value,"onUpdate:modelValue":e[9]||(e[9]=a=>i.value=a),title:l.value.id?"编辑主机":"添加主机",width:"480px",class:"modern-dialog","destroy-on-close":""},{footer:s(()=>[o(r,{onClick:e[8]||(e[8]=a=>i.value=!1)},{default:s(()=>[...e[16]||(e[16]=[v("取消",-1)])]),_:1}),o(r,{type:"primary",onClick:E},{default:s(()=>[...e[17]||(e[17]=[v("保存",-1)])]),_:1})]),default:s(()=>[o(h,{model:l.value,"label-width":"90px"},{default:s(()=>[o(u,{label:"名称",required:""},{default:s(()=>[o(m,{modelValue:l.value.name,"onUpdate:modelValue":e[1]||(e[1]=a=>l.value.name=a),placeholder:"如 PVE-01"},null,8,["modelValue"])]),_:1}),o(u,{label:"地址",required:""},{default:s(()=>[o(m,{modelValue:l.value.hostname,"onUpdate:modelValue":e[2]||(e[2]=a=>l.value.hostname=a),placeholder:"192.168.1.100"},null,8,["modelValue"])]),_:1}),o(u,{label:"节点名"},{default:s(()=>[o(m,{modelValue:l.value.node_name,"onUpdate:modelValue":e[3]||(e[3]=a=>l.value.node_name=a),placeholder:"pve默认"},null,8,["modelValue"])]),_:1}),o(u,{label:"端口"},{default:s(()=>[o(U,{modelValue:l.value.port,"onUpdate:modelValue":e[4]||(e[4]=a=>l.value.port=a),min:1,max:65535,style:{width:"100%"}},null,8,["modelValue"])]),_:1}),o(u,{label:"用户名",required:""},{default:s(()=>[o(m,{modelValue:l.value.username,"onUpdate:modelValue":e[5]||(e[5]=a=>l.value.username=a),placeholder:"root@pam 或 root@vmbr0"},null,8,["modelValue"])]),_:1}),o(u,{label:"密码",required:!l.value.id},{default:s(()=>[o(m,{modelValue:l.value.password,"onUpdate:modelValue":e[6]||(e[6]=a=>l.value.password=a),type:"password","show-password":"",placeholder:l.value.id?"留空保持不变":"必填"},null,8,["modelValue","placeholder"])]),_:1},8,["required"]),o(u,{label:"SSL 验证"},{default:s(()=>[o(H,{modelValue:l.value.verify_ssl,"onUpdate:modelValue":e[7]||(e[7]=a=>l.value.verify_ssl=a)},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])]),_:1},8,["modelValue","title"])])}}},te=T(le,[["__scopeId","data-v-bd0711be"]]);export{te as default};

467
web/dist/assets/Topology-Dk9gGVfs.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

96
web/dist/assets/index-ybrLS-Uj.js vendored Normal file

File diff suppressed because one or more lines are too long

2
web/dist/index.html vendored
View File

@@ -5,7 +5,7 @@
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>局域网机器管理后台</title>
<script type="module" crossorigin src="/assets/index-lpjSKhQ-.js"></script>
<script type="module" crossorigin src="/assets/index-ybrLS-Uj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHYRt9JF.css">
</head>
<body>

View File

@@ -69,6 +69,8 @@ export const fetchPVEHost = (id) => api.get(`/pve/hosts/${id}`)
export const createPVEHost = (data) => api.post('/pve/hosts', data)
export const updatePVEHost = (id, data) => api.put(`/pve/hosts/${id}`, data)
export const deletePVEHost = (id) => api.delete(`/pve/hosts/${id}`)
export const fetchPVEHostStatus = (id) => api.get(`/pve/hosts/${id}/status`)
export const fetchPVEHostVMs = (id) => api.get(`/pve/hosts/${id}/vms`)
// 虚拟机操作
export const fetchVMStatus = (machineId) => api.get(`/machines/${machineId}/vm-status`)

View File

@@ -21,8 +21,11 @@
</div>
</div>
<div class="header-actions" v-if="isAdmin">
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="success" :icon="VideoPlay" :loading="vmLoading" @click="startVM">启动</el-button>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="warning" :icon="VideoPause" :loading="vmLoading" @click="stopVM">关闭</el-button>
<el-tag v-if="machine.pve_host_id && machine.pve_vmid" size="small" :type="vmStatusInfo.status === 'running' ? 'success' : 'info'" effect="light" class="vm-status-tag">
{{ vmStatusInfo.status === 'running' ? '🟢 VM运行中' : vmStatusInfo.status === 'stopped' ? '🔴 VM已停止' : ' VM检测中' }}
</el-tag>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="success" :icon="VideoPlay" :loading="vmLoading" @click="startVM" :disabled="vmStatusInfo.status === 'running'">启动</el-button>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="warning" :icon="VideoPause" :loading="vmLoading" @click="stopVM" :disabled="vmStatusInfo.status === 'stopped'">关闭</el-button>
<el-button text :icon="Edit" @click="openEdit(machine)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="delMachine">删除</el-button>
</div>
@@ -41,6 +44,29 @@
</div>
</div>
<!-- PVE VM Status -->
<div class="card" v-if="machine.pve_host_id && machine.pve_vmid && vmStatusInfo.status">
<div class="card-title">PVE 虚拟机状态</div>
<div class="vm-status-grid">
<div class="vm-stat-item">
<span class="vm-stat-label">运行状态</span>
<span class="vm-stat-value" :class="vmStatusInfo.status">{{ vmStatusInfo.status === 'running' ? '运行中' : '已停止' }}</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.cpu !== undefined">
<span class="vm-stat-label">CPU 使用率</span>
<span class="vm-stat-value">{{ vmStatusInfo.cpu?.toFixed ? vmStatusInfo.cpu.toFixed(1) : vmStatusInfo.cpu }}%</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.memory_used !== undefined && vmStatusInfo.memory_total">
<span class="vm-stat-label">内存使用</span>
<span class="vm-stat-value">{{ formatBytes(vmStatusInfo.memory_used) }} / {{ formatBytes(vmStatusInfo.memory_total) }}</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.uptime">
<span class="vm-stat-label">运行时长</span>
<span class="vm-stat-value">{{ formatUptime(vmStatusInfo.uptime) }}</span>
</div>
</div>
</div>
<!-- System Status -->
<div class="card" v-if="machine.cpu_info || machine.memory_info || machine.disk_info">
<div class="card-title">系统状态</div>
@@ -333,6 +359,7 @@ const machine = ref(null)
const services = ref([])
const relationships = ref([])
const vmLoading = ref(false)
const vmStatusInfo = ref({})
const allMachines = ref([])
const offlineLogs = ref([])
const pveHosts = ref([])
@@ -369,6 +396,7 @@ onMounted(async () => {
const pveRes = await fetchPVEHosts()
pveHosts.value = pveRes.data
} catch (e) {}
await loadVMStatus()
}
await checkAuth()
const interval = uiRefreshInterval || 10000
@@ -378,6 +406,7 @@ onMounted(async () => {
await loadServices()
if (isAdmin) {
await loadRelationships()
await loadVMStatus()
}
}, interval)
}
@@ -467,13 +496,29 @@ async function saveMachine() {
loadMachine()
}
async function startVM() {
vmLoading.value = true
async function loadVMStatus() {
if (!machine.value || !machine.value.pve_host_id || !machine.value.pve_vmid) return
try {
const res = await fetchVMStatus(machineId)
vmStatusInfo.value = res.data
} catch (e) {
vmStatusInfo.value = {}
}
}
async function startVM() {
try {
await ElMessageBox.confirm('确定要启动虚拟机吗?', '确认启动', { type: 'info' })
vmLoading.value = true
await apiStartVM(machineId)
ElMessage.success('虚拟机已启动')
await loadVMStatus()
loadMachine()
} catch (e) {}
} catch (e) {
if (e !== 'cancel') {
// error handled by interceptor
}
}
vmLoading.value = false
}
@@ -483,8 +528,13 @@ async function stopVM() {
vmLoading.value = true
await apiStopVM(machineId)
ElMessage.success('虚拟机已关闭')
await loadVMStatus()
loadMachine()
} catch (e) {}
} catch (e) {
if (e !== 'cancel') {
// error handled by interceptor
}
}
vmLoading.value = false
}
@@ -636,6 +686,26 @@ function formatDuration(seconds) {
const hr = h % 24
return `${d}${hr}${m}`
}
function formatBytes(bytes) {
if (!bytes && bytes !== 0) return '-'
const b = Number(bytes)
if (b < 1024) return `${b}B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)}MB`
return `${(b / 1024 / 1024 / 1024).toFixed(1)}GB`
}
function formatUptime(seconds) {
if (!seconds && seconds !== 0) return '-'
const s = Number(seconds)
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
if (d > 0) return `${d}${h}${m}`
if (h > 0) return `${h}${m}`
return `${m}`
}
</script>
<style scoped>
@@ -668,6 +738,55 @@ function formatDuration(seconds) {
.status-badge.offline { background: rgba(239,68,68,0.10); color: #b91c1c; }
html.dark .status-badge.online { background: rgba(52,211,153,0.15); color: #34d399; }
html.dark .status-badge.offline { background: rgba(248,113,113,0.15); color: #f87171; }
.vm-status-tag {
font-size: 12px;
height: 24px;
margin-right: 4px;
}
.vm-status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vm-stat-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
background: var(--surface-hover);
border-radius: 8px;
}
.vm-stat-label {
font-size: 11px;
color: var(--text-muted);
}
.vm-stat-value {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.vm-stat-value.running {
color: #15803d;
}
.vm-stat-value.stopped {
color: #b91c1c;
}
html.dark .vm-stat-value.running {
color: #34d399;
}
html.dark .vm-stat-value.stopped {
color: #f87171;
}
.host-subtitle {
margin-top: 4px;
font-size: 13px;

View File

@@ -12,18 +12,26 @@
<div class="host-header">
<el-icon class="host-icon"><Monitor /></el-icon>
<span class="host-name">{{ h.name }}</span>
<el-tag size="small" :type="hostStatus[h.id] === 'online' ? 'success' : hostStatus[h.id] === 'offline' ? 'danger' : 'info'" effect="light" class="status-tag">
{{ hostStatus[h.id] === 'online' ? '在线' : hostStatus[h.id] === 'offline' ? '离线' : '检测中' }}
</el-tag>
</div>
<div class="host-info">
<div class="info-row">
<span class="label">地址</span>
<span class="value">{{ h.hostname }}:{{ h.port }}</span>
</div>
<div class="info-row">
<span class="label">节点</span>
<span class="value">{{ h.node_name || 'pve' }}</span>
</div>
<div class="info-row">
<span class="label">用户</span>
<span class="value">{{ h.username }}</span>
</div>
</div>
<div class="host-actions">
<el-button size="small" :icon="View" @click="openVMList(h)">查看虚拟机</el-button>
<el-button size="small" @click="openEdit(h)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(h)">删除</el-button>
</div>
@@ -60,19 +68,45 @@
<el-button type="primary" @click="saveHost">保存</el-button>
</template>
</el-dialog>
<!-- VM List Dialog -->
<el-dialog v-model="vmDialogVisible" :title="`${selectedHost?.name || ''} — 虚拟机列表`" width="600px" class="modern-dialog" destroy-on-close>
<el-empty v-if="!vmLoading && !vmList.length" description="该节点上暂无虚拟机" />
<div v-else-if="vmLoading" class="vm-loading">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>正在获取虚拟机列表...</span>
</div>
<div v-else class="vm-list">
<div v-for="vm in vmList" :key="vm.vmid" class="vm-item">
<div class="vm-main">
<span class="vm-id">VM {{ vm.vmid }}</span>
<span class="vm-name">{{ vm.name }}</span>
</div>
<el-tag size="small" :type="vm.status === 'running' ? 'success' : 'info'" effect="light">
{{ vm.status === 'running' ? '运行中' : '已停止' }}
</el-tag>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus, Monitor } from '@element-plus/icons-vue'
import { fetchPVEHosts, createPVEHost, updatePVEHost, deletePVEHost } from '@/api'
import { Plus, Monitor, View, Loading } from '@element-plus/icons-vue'
import { fetchPVEHosts, createPVEHost, updatePVEHost, deletePVEHost, fetchPVEHostStatus, fetchPVEHostVMs } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const hosts = ref([])
const dialogVisible = ref(false)
const editing = ref({ name: '', hostname: '', port: 8006, node_name: 'pve', username: '', password: '', verify_ssl: false })
const vmDialogVisible = ref(false)
const vmLoading = ref(false)
const vmList = ref([])
const selectedHost = ref(null)
const hostStatus = ref({})
onMounted(() => {
load()
})
@@ -80,6 +114,19 @@ onMounted(() => {
async function load() {
const res = await fetchPVEHosts()
hosts.value = res.data
// 检测每个节点的连接状态
for (const h of hosts.value) {
checkHostStatus(h.id)
}
}
async function checkHostStatus(id) {
try {
const res = await fetchPVEHostStatus(id)
hostStatus.value[id] = res.data.status
} catch (e) {
hostStatus.value[id] = 'offline'
}
}
function openEdit(item) {
@@ -124,6 +171,20 @@ async function handleDelete(host) {
}
}
}
async function openVMList(host) {
selectedHost.value = host
vmDialogVisible.value = true
vmLoading.value = true
vmList.value = []
try {
const res = await fetchPVEHostVMs(host.id)
vmList.value = res.data || []
} catch (e) {
ElMessage.error('获取虚拟机列表失败')
}
vmLoading.value = false
}
</script>
<style scoped>
@@ -141,7 +202,7 @@ async function handleDelete(host) {
.hosts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
@@ -167,6 +228,13 @@ async function handleDelete(host) {
.host-name {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.status-tag {
font-size: 11px;
height: 20px;
padding: 0 8px;
}
.host-info {
@@ -192,4 +260,60 @@ async function handleDelete(host) {
display: flex;
gap: 8px;
}
.vm-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px 0;
color: var(--text-secondary);
}
.loading-icon {
font-size: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.vm-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.vm-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--surface-hover);
border-radius: 8px;
}
.vm-main {
display: flex;
align-items: center;
gap: 10px;
}
.vm-id {
font-size: 12px;
font-weight: 700;
color: var(--el-color-primary);
background: var(--surface);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
}
.vm-name {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
</style>