功能:扩展云管资源运维、账号授权及监控管理

This commit is contained in:
songyiming committed 2026-11-10 17:52:52 +08:00
1 parent eef485306f
commit 6134d49c16
87 files changed
+29498 -126

No files matched your search

+160 -19
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jmoiron/sqlx"
"log"
"net"
"strings"
@@ -25,7 +26,7 @@ func (s *Server) overview(c *gin.Context) {
Sessions int `db:"sessions"`
Failed int `db:"failed"`
}
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`)
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE enabled AND last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE enabled AND session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`)
if err != nil {
s.dbError(c, err)
return
@@ -33,14 +34,15 @@ func (s *Server) overview(c *gin.Context) {
c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode})
}
func (s *Server) settings(c *gin.Context) {
pve := s.currentPVE()
provider := "unconfigured"
if s.Config.Mode == "demo" {
provider = "demo"
}
if s.PVE != nil {
if pve != nil {
provider = "proxmox"
}
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}})
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": pve != nil, "terminalTransport": "https-poll", "capabilities": []string{"terminal-system-metrics", "gpu-inventory", "desktop-batches", "desktop-snapshots", "pve-connection", "desktop-hardware", "login-rate-limit", "csv-imports", "session-history", "monitor-alerts", "administrators", "password", "auth", "resources", "desktops", "templates", "pools", "clone", "desktop-connection", "terminals", "terminal-lifecycle", "terminal-policies", "users", "batch-bindings", "bindings", "tasks", "audit"}})
}
func (s *Server) resources(c *gin.Context) {
items := []model.Resource{}
@@ -70,7 +72,7 @@ func (s *Server) desktop(c *gin.Context) {
}
func (s *Server) terminals(c *gin.Context) {
items := []model.Terminal{}
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
err := s.DB.SelectContext(c.Request.Context(), &items, terminalListSQL+` WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
if err != nil {
s.dbError(c, err)
return
@@ -135,6 +137,9 @@ func (s *Server) bindings(c *gin.Context) {
return
}
defer tx.Rollback()
if !s.lockAuthorization(c, tx) {
return
}
var user model.User
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
s.dbError(c, err)
@@ -171,6 +176,10 @@ func (s *Server) bindings(c *gin.Context) {
return
}
}
if _, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND (terminal_id=$1 OR terminal_id=$2)", user.TerminalID, in.TerminalID); err != nil {
s.dbError(c, err)
return
}
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
@@ -211,11 +220,12 @@ func (s *Server) audits(c *gin.Context) {
list(c, items)
}
func (s *Server) discoverVMs(c *gin.Context) {
if s.PVE == nil {
pve := s.currentPVE()
if pve == nil {
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
return
}
items, err := s.PVE.Resources(c.Request.Context())
items, err := pve.Resources(c.Request.Context())
if err != nil {
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
return
@@ -229,13 +239,15 @@ func (s *Server) discoverVMs(c *gin.Context) {
list(c, vms)
}
func (s *Server) syncResources(c *gin.Context) {
if s.PVE == nil {
pve := s.currentPVE()
if pve == nil {
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
return
}
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
}
func (s *Server) importDesktop(c *gin.Context) {
pve := s.currentPVE()
var in struct {
Name string `json:"name" binding:"required,max=80"`
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
@@ -249,7 +261,7 @@ func (s *Server) importDesktop(c *gin.Context) {
if !s.decode(c, &in) {
return
}
if s.PVE == nil {
if pve == nil {
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
return
}
@@ -261,7 +273,7 @@ func (s *Server) importDesktop(c *gin.Context) {
}
}
}
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
power, err := pve.Power(c.Request.Context(), in.Node, in.VMID)
if err != nil {
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
return
@@ -290,6 +302,7 @@ func (s *Server) importDesktop(c *gin.Context) {
s.desktop(c)
}
func (s *Server) desktopAction(c *gin.Context) {
pve := s.currentPVE()
var in struct {
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
}
@@ -305,10 +318,19 @@ func (s *Server) desktopAction(c *gin.Context) {
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
return
}
if d.Provider == "proxmox" && s.PVE == nil {
if d.Provider == "proxmox" && pve == nil {
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
return
}
unresolved, err := maintenanceBlocked(c.Request.Context(), s.DB, d.ID, "", false)
if err != nil {
s.dbError(c, err)
return
}
if unresolved {
s.fail(c, 409, "hardware_unconfirmed", "请先核对结果未确认的桌面维护任务")
return
}
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
}
func (s *Server) terminalCommand(c *gin.Context) {
@@ -353,6 +375,33 @@ func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName s
return
}
defer tx.Rollback()
if kind == "desktop" {
var id string
if err = tx.GetContext(ctx, &id, "SELECT id FROM desktops WHERE id=$1 FOR UPDATE", targetID); err != nil {
s.dbError(c, err)
return
}
unresolved, err := maintenanceBlocked(ctx, tx, targetID, "", false)
if err != nil {
s.dbError(c, err)
return
}
if unresolved {
s.fail(c, 409, "hardware_unconfirmed", "请先核对未确认的桌面维护任务")
return
}
}
if kind == "terminal" {
var enabled bool
if err = tx.GetContext(ctx, &enabled, "SELECT enabled FROM terminals WHERE id=$1 FOR UPDATE", targetID); err != nil {
s.dbError(c, err)
return
}
if !enabled {
s.fail(c, 409, "terminal_disabled", "终端已停用,请重新激活后操作")
return
}
}
var task model.Task
err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key)
if err != nil {
@@ -381,6 +430,12 @@ func (s *Server) complete(ctx context.Context, t model.Task, status, message str
return err
}
defer tx.Rollback()
if err = s.completeTx(ctx, tx, t, status, message); err != nil {
return err
}
return tx.Commit()
}
func (s *Server) completeTx(ctx context.Context, tx *sqlx.Tx, t model.Task, status, message string) error {
result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID)
if err != nil {
return err
@@ -395,7 +450,7 @@ func (s *Server) complete(ctx context.Context, t model.Task, status, message str
if err != nil {
return err
}
return tx.Commit()
return nil
}
func ratio(a, b float64) float64 {
if b <= 0 {
@@ -404,7 +459,14 @@ func ratio(a, b float64) float64 {
return a / b
}
func (s *Server) sync(ctx context.Context) error {
items, err := s.PVE.Resources(ctx)
if err := s.RefreshCompute(ctx); err != nil {
return err
}
pve := s.currentPVE()
if pve == nil {
return nil
}
items, err := pve.Resources(ctx)
if err != nil {
return err
}
@@ -421,6 +483,9 @@ func (s *Server) sync(ctx context.Context) error {
if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO resource_samples(resource_id,cpu,memory,storage) VALUES($1,CASE WHEN $7 THEN $2::double precision ELSE NULL END,CASE WHEN $7 AND $3::double precision>0 THEN $4::double precision ELSE NULL END,CASE WHEN $7 AND $5::double precision>0 THEN $6::double precision ELSE NULL END)`, r.Node, r.CPU, r.MaxMemory, ratio(r.Memory, r.MaxMemory), r.MaxDisk, ratio(r.Disk, r.MaxDisk), r.Status == "online"); err != nil {
return err
}
}
if r.Type == "qemu" {
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil {
@@ -432,26 +497,81 @@ func (s *Server) sync(ctx context.Context) error {
}
func (s *Server) RunWorker(ctx context.Context) {
// Hold a session lock so only one worker can resume PVE tasks across API replicas.
conn, err := s.DB.Connx(ctx)
if err != nil {
return
}
defer conn.Close()
for {
var acquired bool
if err = conn.GetContext(ctx, &acquired, "SELECT pg_try_advisory_lock(7651092027)"); err != nil {
return
}
if acquired {
break
}
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
}
defer conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock(7651092027)")
workerCtx, stop := context.WithCancel(ctx)
provisionDone := make(chan struct{}, 1)
provisionBusy := false
defer func() {
stop()
if provisionBusy {
<-provisionDone
}
}()
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-provisionDone:
provisionBusy = false
case <-ticker.C:
if err = conn.PingContext(ctx); err != nil {
return
}
// Unknown outcomes are never automatically retried after a worker restart.
var expired []model.Task
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at<now()-interval '2 minutes'") == nil {
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND ((kind NOT IN ('provision','hardware','snapshot','batch-desktop') AND created_at<now()-interval '2 minutes') OR (kind='batch-desktop' AND ((status='queued' AND created_at<now()-interval '2 hours') OR (status='running' AND updated_at<now()-interval '2 minutes'))) OR (kind IN ('provision','hardware','snapshot') AND updated_at<now()-interval '35 minutes'))") == nil {
for _, t := range expired {
if t.Kind == "batch-desktop" {
s.finishBatchPower(t, "", "expired", "")
continue
}
if t.Kind == "snapshot" {
s.finishSnapshot(t, "", "expired", "")
continue
}
if t.Kind == "hardware" {
s.finishHardware(t, "", "expired", "")
continue
}
if t.Kind == "provision" {
_, _ = s.DB.ExecContext(ctx, "UPDATE provisioning_jobs SET state='uncertain' WHERE task_id=$1 AND state='reserved'", t.ID)
}
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
}
}
var t model.Task
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE status='queued' AND kind!='terminal' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE ((status='queued' AND kind!='terminal') OR (kind='provision' AND status='running' AND provider_ref!='') OR (kind IN ('hardware','snapshot','batch-desktop') AND status='running')) AND ($1 OR kind NOT IN ('provision','hardware','snapshot','batch-desktop')) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`, !provisionBusy)
if err != nil {
continue
}
s.execute(ctx, t)
if t.Kind == "provision" || t.Kind == "hardware" || t.Kind == "snapshot" || t.Kind == "batch-desktop" {
provisionBusy = true
go func(task model.Task) { s.execute(workerCtx, task); provisionDone <- struct{}{} }(t)
} else {
s.execute(ctx, t)
}
}
}
}
@@ -474,6 +594,27 @@ func (s *Server) RunSync(ctx context.Context) {
}
}
func (s *Server) execute(parent context.Context, t model.Task) {
if t.Kind == "batch-desktop" {
s.executeBatchPower(parent, t)
return
}
if t.Kind == "snapshot" {
s.executeSnapshot(parent, t)
return
}
if t.Kind == "hardware" {
s.executeHardware(parent, t)
return
}
if err := s.RefreshCompute(parent); err != nil {
_ = s.complete(parent, t, "failed", "计算平台配置无法加载,未提交操作")
return
}
pve := s.currentPVE()
if t.Kind == "provision" {
s.executeProvision(parent, t)
return
}
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
defer cancel()
var err error
@@ -503,11 +644,11 @@ func (s *Server) execute(parent context.Context, t model.Task) {
}
}
} else if err == nil {
if s.PVE == nil {
if pve == nil {
err = errors.New("PVE unconfigured")
} else {
var upid string
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
upid, err = pve.Action(ctx, d.Node, d.VMID, t.Action)
uncertain = true
var httpError *proxmox.HTTPError
if errors.As(err, &httpError) && httpError.Status >= 400 && httpError.Status < 500 {
@@ -517,11 +658,11 @@ func (s *Server) execute(parent context.Context, t model.Task) {
_, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID)
}
if err == nil {
err = s.PVE.Wait(ctx, d.Node, upid, time.Second)
err = pve.Wait(ctx, d.Node, upid, time.Second)
}
if err == nil {
var power string
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
power, err = pve.Power(ctx, d.Node, d.VMID)
if err == nil {
expected := "running"
if t.Action == "shutdown" {