vdi-cloud/platform/internal/server/admin.go

554 lines
18 KiB
Go
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.

package server
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
"log"
"net"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
func (s *Server) overview(c *gin.Context) {
var counts struct {
Terminals int `db:"terminals"`
Online int `db:"online"`
Desktops int `db:"desktops"`
Running int `db:"running"`
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`)
if err != nil {
s.dbError(c, err)
return
}
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) {
provider := "unconfigured"
if s.Config.Mode == "demo" {
provider = "demo"
}
if s.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"}})
}
func (s *Server) resources(c *gin.Context) {
items := []model.Resource{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM resources ORDER BY name"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) desktops(c *gin.Context) {
items := []model.Desktop{}
q := "%" + c.Query("keyword") + "%"
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT * FROM desktops WHERE (name ILIKE $1 OR vmid::text ILIKE $1) AND ($2='' OR power_state=$2) ORDER BY name`, q, c.Query("status"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) desktop(c *gin.Context) {
var d model.Desktop
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, d)
}
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")+"%")
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) users(c *gin.Context) {
items, err := store.Users(c.Request.Context(), s.DB)
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) createUser(c *gin.Context) {
var in struct {
Username string `json:"username" binding:"required,min=2,max=80"`
DisplayName string `json:"displayName" binding:"required,max=80"`
}
if !s.decode(c, &in) {
return
}
in.Username = strings.TrimSpace(in.Username)
in.DisplayName = strings.TrimSpace(in.DisplayName)
if in.Username == "" || in.DisplayName == "" {
s.fail(c, 400, "invalid_request", "账号和姓名不能为空")
return
}
id := store.ID("user_")
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, in.Username, in.DisplayName)
if err != nil {
if conflict(err) {
s.fail(c, 409, "conflict", "该账号已存在")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "create_user", id, "success")
c.JSON(201, model.User{ID: id, Username: in.Username, DisplayName: in.DisplayName, Enabled: true, DesktopIDs: []string{}})
}
func conflict(err error) bool {
var e *pgconn.PgError
return errors.As(err, &e) && (e.Code == "23505" || e.Code == "23503")
}
func (s *Server) bindings(c *gin.Context) {
var in struct {
TerminalID *string `json:"terminalId"`
DesktopIDs []string `json:"desktopIds" binding:"max=100"`
Enabled *bool `json:"enabled"`
}
if !s.decode(c, &in) {
return
}
if in.TerminalID != nil && *in.TerminalID == "" {
in.TerminalID = nil
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
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)
return
}
if in.Enabled != nil {
user.Enabled = *in.Enabled
}
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1,enabled=$2 WHERE id=$3", in.TerminalID, user.Enabled, user.ID)
if err != nil {
if conflict(err) {
s.fail(c, 409, "binding_conflict", "终端不存在或已关联其他用户")
} else {
s.dbError(c, err)
}
return
}
if _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1", user.ID); err != nil {
s.dbError(c, err)
return
}
seen := map[string]bool{}
for _, id := range in.DesktopIDs {
if seen[id] {
continue
}
seen[id] = true
if _, err = tx.ExecContext(ctx, "INSERT INTO bindings VALUES($1,$2)", user.ID, id); err != nil {
if conflict(err) {
s.fail(c, 400, "invalid_desktop", "所选桌面不存在")
} else {
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()
}
if err != nil {
s.dbError(c, err)
return
}
user.TerminalID = in.TerminalID
user.DesktopIDs = in.DesktopIDs
if user.DesktopIDs == nil {
user.DesktopIDs = []string{}
}
c.JSON(200, user)
}
func (s *Server) tasks(c *gin.Context) {
items := []model.Task{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 1000"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) task(c *gin.Context) {
var t model.Task
if err := s.DB.GetContext(c.Request.Context(), &t, "SELECT * FROM tasks WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, t)
}
func (s *Server) audits(c *gin.Context) {
items := []model.Audit{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1000"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) discoverVMs(c *gin.Context) {
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
return
}
items, err := s.PVE.Resources(c.Request.Context())
if err != nil {
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
return
}
vms := []gin.H{}
for _, r := range items {
if r.Type == "qemu" && r.Template == 0 {
vms = append(vms, gin.H{"vmid": r.VMID, "node": r.Node, "name": r.Name, "status": r.Status})
}
}
list(c, vms)
}
func (s *Server) syncResources(c *gin.Context) {
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
return
}
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
}
func (s *Server) importDesktop(c *gin.Context) {
var in struct {
Name string `json:"name" binding:"required,max=80"`
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
Node string `json:"node" binding:"required,max=100"`
VMID int `json:"vmid" binding:"min=100"`
Endpoint string `json:"endpoint" binding:"max=253"`
Width int `json:"width" binding:"omitempty,min=640,max=7680"`
Height int `json:"height" binding:"omitempty,min=480,max=4320"`
FPS int `json:"fps" binding:"omitempty,min=10,max=120"`
}
if !s.decode(c, &in) {
return
}
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
return
}
if in.Endpoint != "" && net.ParseIP(in.Endpoint) == nil {
for _, part := range strings.Split(in.Endpoint, ".") {
if part == "" || strings.ContainsAny(part, "/:@ \\") {
s.fail(c, 400, "invalid_endpoint", "连接地址应为 IP 或主机名")
return
}
}
}
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
if err != nil {
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
return
}
id := store.ID("desk_")
if in.Width == 0 {
in.Width = 2560
}
if in.Height == 0 {
in.Height = 1440
}
if in.FPS == 0 {
in.FPS = 30
}
_, err = s.DB.ExecContext(c.Request.Context(), `INSERT INTO desktops(id,name,os,node,vmid,power_state,provider,endpoint,width,height,fps) VALUES($1,$2,$3,$4,$5,$6,'proxmox',$7,$8,$9,$10)`, id, in.Name, in.OS, in.Node, in.VMID, power, in.Endpoint, in.Width, in.Height, in.FPS)
if err != nil {
if conflict(err) {
s.fail(c, 409, "already_managed", "该 VM 已纳管")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "import_desktop", id, "success")
c.Params = append(c.Params, gin.Param{Key: "id", Value: id})
s.desktop(c)
}
func (s *Server) desktopAction(c *gin.Context) {
var in struct {
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
}
if !s.decode(c, &in) {
return
}
var d model.Desktop
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if d.Provider == "demo" && s.Config.Mode != "demo" {
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
return
}
if d.Provider == "proxmox" && s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
return
}
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
}
func (s *Server) terminalCommand(c *gin.Context) {
var in struct {
Action string `json:"action" binding:"required,oneof=reboot reset-session collect-logs"`
}
if !s.decode(c, &in) {
return
}
var name string
if err := s.DB.GetContext(c.Request.Context(), &name, "SELECT name FROM terminals WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
s.submitTask(c, "terminal", in.Action, c.Param("id"), name)
}
func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName string) {
key := c.GetHeader("Idempotency-Key")
if key == "" || len(key) > 100 {
s.fail(c, 400, "idempotency_required", "请提供不超过 100 字符的 Idempotency-Key")
return
}
actor := s.admin(c).Username
ctx := c.Request.Context()
var existing model.Task
err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key)
if err == nil {
if existing.Kind != kind || existing.Action != action || existing.TargetID != targetID {
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同操作")
return
}
c.JSON(202, existing)
return
}
if !errors.Is(err, sql.ErrNoRows) {
s.dbError(c, err)
return
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
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 {
if conflict(err) {
s.fail(c, 409, "task_conflict", "已有相同请求或该对象有任务执行中,请刷新任务列表")
} else {
s.dbError(c, err)
}
return
}
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,'submitted',$5,now())", store.ID("audit_"), actor, action, targetName, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(202, task)
}
// Complete persists the final result and its audit entry together.
func (s *Server) complete(ctx context.Context, t model.Task, status, message string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
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
}
n, _ := result.RowsAffected()
if n == 0 {
return nil
}
// Session release is scoped to a session ID by the device event endpoint.
// A delayed command acknowledgement must not clear a newer connection.
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,$5,$6,now())", store.ID("audit_"), t.Actor, t.Action, t.TargetName, status, t.ID)
if err != nil {
return err
}
return tx.Commit()
}
func ratio(a, b float64) float64 {
if b <= 0 {
return 0
}
return a / b
}
func (s *Server) sync(ctx context.Context) error {
items, err := s.PVE.Resources(ctx)
if err != nil {
return err
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, "DELETE FROM resources WHERE provider='proxmox'"); err != nil {
return err
}
for _, r := range items {
if r.Type == "node" {
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 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 {
return err
}
}
}
return tx.Commit()
}
func (s *Server) RunWorker(ctx context.Context) {
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// 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 {
for _, t := range expired {
_ = 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 *`)
if err != nil {
continue
}
s.execute(ctx, t)
}
}
}
// Resource reads are independent of the command queue and never change VM power.
func (s *Server) RunSync(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
readCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if err := s.sync(readCtx); err != nil {
log.Printf("PVE resource sync failed: %v", err)
}
cancel()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Server) execute(parent context.Context, t model.Task) {
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
defer cancel()
var err error
uncertain := false
message := "已收到执行结果"
if t.Kind == "resource" {
err = s.sync(ctx)
} else {
var d model.Desktop
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
if err == nil && d.Provider == "demo" {
if s.Config.Mode != "demo" {
err = errors.New("demo disabled")
} else {
select {
case <-ctx.Done():
err = ctx.Err()
case <-time.After(500 * time.Millisecond):
}
if err == nil {
power := "running"
if t.Action == "shutdown" {
power = "stopped"
}
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
message = "演示计算任务已完成;未检测真实连接服务"
}
}
} else if err == nil {
if s.PVE == nil {
err = errors.New("PVE unconfigured")
} else {
var upid string
upid, err = s.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 {
uncertain = false
}
if err == nil {
_, 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)
}
if err == nil {
var power string
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
if err == nil {
expected := "running"
if t.Action == "shutdown" {
expected = "stopped"
}
if power != expected {
err = fmt.Errorf("power result not confirmed")
} else {
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
}
}
}
}
}
}
status := "success"
if err != nil {
log.Printf("task execution error taskId=%s error=%v", t.ID, err)
status = "failed"
message = "执行失败,请检查服务日志及 PVE 任务;未自动重试"
if (uncertain && !errors.Is(err, proxmox.ErrTaskFailed)) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
message = "结果未确认,请核对实际状态;未自动重试"
}
}
finalCtx, finalCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer finalCancel()
_ = s.complete(finalCtx, t, status, message)
}