From a9784539eab46a74948558748917b1b42ff065a1 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:24:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=BF=AB=E7=85=A7=E5=92=8C=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/handlers.go | 5 + backend/internal/api/oversell.go | 3 + backend/internal/api/snapshots.go | 206 ++++++++++ backend/internal/api/subuser.go | 6 + backend/internal/api/taskqueue.go | 6 + backend/internal/config/config.go | 205 ++++++++-- backend/internal/lxc/lxc.go | 13 + backend/internal/lxc/snapshot.go | 349 ++++++++++++++++ backend/internal/server/server.go | 1 + backend/main.go | 3 + frontend/src/App.tsx | 2 + .../src/components/CreateContainerModal.tsx | 19 +- frontend/src/components/Sidebar.tsx | 14 + frontend/src/pages/ContainerDetail.tsx | 381 ++++++++++++++++++ frontend/src/pages/Containers.tsx | 7 + frontend/src/pages/Snapshots.tsx | 108 +++++ frontend/src/services/api.ts | 64 +++ 17 files changed, 1352 insertions(+), 40 deletions(-) create mode 100644 backend/internal/api/snapshots.go create mode 100644 backend/internal/lxc/snapshot.go create mode 100644 frontend/src/pages/Snapshots.tsx diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 8034fdf..ac30a67 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -69,6 +69,8 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { updateExpiry(w, r, id) case action == "ipv6" && r.Method == http.MethodPost: assignIPv6(w, r, id) + case action == "snapshots" || strings.HasPrefix(action, "snapshots/"): + handleContainerSnapshots(w, r, id, action) case action == "port-mappings" && r.Method == http.MethodPost: addPortMapping(w, r, id) case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut: @@ -121,6 +123,9 @@ func createContainer(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"}) return } + if cfg.SnapshotLimit <= 0 { + cfg.SnapshotLimit = config.DefaultSnapshotLimit + } if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) return diff --git a/backend/internal/api/oversell.go b/backend/internal/api/oversell.go index 2830cf9..da0eb82 100644 --- a/backend/internal/api/oversell.go +++ b/backend/internal/api/oversell.go @@ -34,6 +34,9 @@ func updateOversell(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) return } + if cfg.SubUserSnapshotLimit <= 0 { + cfg.SubUserSnapshotLimit = 3 + } // Apply KSM if cfg.KSMEnabled { diff --git a/backend/internal/api/snapshots.go b/backend/internal/api/snapshots.go new file mode 100644 index 0000000..e12fa70 --- /dev/null +++ b/backend/internal/api/snapshots.go @@ -0,0 +1,206 @@ +package api + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "clicd/internal/config" +) + +func HandleSnapshots(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + snapshots := append([]config.Snapshot(nil), config.AppConfig.Snapshots...) + sortSnapshotsNewestFirst(snapshots) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: snapshots}) +} + +func handleContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int, action string) { + switch { + case action == "snapshots" && r.Method == http.MethodGet: + listContainerSnapshots(w, r, containerID) + case action == "snapshots" && r.Method == http.MethodPost: + createContainerSnapshot(w, r, containerID) + case action == "snapshots/schedule" && r.Method == http.MethodPost: + updateSnapshotSchedule(w, r, containerID) + case action == "snapshots/quota" && r.Method == http.MethodPut: + updateSnapshotQuota(w, r, containerID) + case strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost: + snapshotID := strings.TrimSuffix(strings.TrimPrefix(action, "snapshots/"), "/restore") + restoreContainerSnapshot(w, r, containerID, snapshotID) + case strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete: + snapshotID := strings.TrimPrefix(action, "snapshots/") + deleteContainerSnapshot(w, r, containerID, snapshotID) + default: + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot action not found"}) + } +} + +func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID int) { + c := config.FindContainer(containerID) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + snapshots := config.ContainerSnapshots(containerID) + sortSnapshotsNewestFirst(snapshots) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{ + "snapshots": snapshots, + "quota": config.ContainerSnapshotLimit(c), + "schedule": map[string]interface{}{ + "enabled": c.SnapshotScheduleEnabled, + "interval_hours": c.SnapshotScheduleIntervalHours, + "time": c.SnapshotScheduleTime, + "last_run": c.SnapshotScheduleLastRun, + "next_run": c.SnapshotScheduleNextRun, + "created_by": c.SnapshotScheduleCreatedBy, + }, + }}) +} + +func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) { + user := requestUser(r) + if isSubUserRequest(r) { + c := config.FindContainer(containerID) + limit := config.ContainerSnapshotLimit(c) + if len(config.ContainerSnapshots(containerID)) >= limit { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Snapshot quota reached. Delete an old snapshot first."}) + return + } + } + snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + config.AddAuditLog("snapshot.create", snapshot.ContainerName, snapshot.ID, user) + jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Data: snapshot}) +} + +func updateSnapshotQuota(w http.ResponseWriter, r *http.Request, containerID int) { + if isSubUserRequest(r) { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot change snapshot quota"}) + return + } + var req struct { + SnapshotLimit int `json:"snapshot_limit"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + if req.SnapshotLimit <= 0 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot quota must be at least 1"}) + return + } + c := config.FindContainer(containerID) + if c == nil { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) + return + } + c.SnapshotLimit = req.SnapshotLimit + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"}) + return + } + user := requestUser(r) + config.AddAuditLog("snapshot.quota", c.Name, "limit="+strconv.Itoa(req.SnapshotLimit), user) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{ + "container": c, + "quota": c.SnapshotLimit, + }}) +} + +func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID int) { + var req struct { + Enabled bool `json:"enabled"` + IntervalHours int `json:"interval_hours"` + Time string `json:"time"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + if req.IntervalHours <= 0 { + req.IntervalHours = 24 + } + if req.IntervalHours < 24 { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Snapshot schedule interval cannot be less than 24 hours"}) + return + } + if req.Time == "" { + req.Time = "03:00" + } + user := requestUser(r) + c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user) + if err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + + if req.Enabled { + config.AddAuditLog("snapshot.schedule", c.Name, "enabled", user) + } else { + config.AddAuditLog("snapshot.schedule", c.Name, "disabled", user) + } + + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{ + "container": c, + }}) +} + +func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) { + snapshot := config.FindSnapshot(snapshotID) + if snapshot == nil || snapshot.ContainerID != containerID { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"}) + return + } + user := requestUser(r) + if err := lxcManager.DeleteSnapshot(snapshotID); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + config.AddAuditLog("snapshot.delete", snapshot.ContainerName, snapshot.ID, user) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot deleted"}) +} + +func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int, snapshotID string) { + snapshot := config.FindSnapshot(snapshotID) + if snapshot == nil || snapshot.ContainerID != containerID { + jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"}) + return + } + user := requestUser(r) + if err := lxcManager.RestoreSnapshot(snapshotID); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()}) + return + } + config.AddAuditLog("snapshot.restore", snapshot.ContainerName, snapshot.ID, user) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Snapshot restored"}) +} + +func requestUser(r *http.Request) string { + if claims, ok := claimsFromRequest(r); ok { + if subUser, _ := claims["sub_user"].(string); subUser != "" { + return "user:" + subUser + } + if username, _ := claims["username"].(string); username != "" { + return username + } + } + return "admin" +} + +func sortSnapshotsNewestFirst(snapshots []config.Snapshot) { + sort.SliceStable(snapshots, func(i, j int) bool { + ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt) + tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt) + return tj.Before(ti) + }) +} diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go index 624a9ad..f65667d 100644 --- a/backend/internal/api/subuser.go +++ b/backend/internal/api/subuser.go @@ -377,6 +377,12 @@ func isSubUserContainerActionAllowed(action string, method string) bool { switch { case action == "usage" || action == "traffic" || action == "random-port": return method == http.MethodGet + case action == "snapshots": + return method == http.MethodGet || method == http.MethodPost + case action == "snapshots/schedule": + return method == http.MethodPost + case strings.HasPrefix(action, "snapshots/"): + return method == http.MethodDelete || method == http.MethodPost case action == "start" || action == "stop" || action == "restart" || action == "reinstall": return method == http.MethodPost case strings.HasPrefix(action, "port-mappings/"): diff --git a/backend/internal/api/taskqueue.go b/backend/internal/api/taskqueue.go index bfd6207..794b79a 100644 --- a/backend/internal/api/taskqueue.go +++ b/backend/internal/api/taskqueue.go @@ -504,6 +504,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) { if req.Containers[i].DiskGB < 1 { req.Containers[i].DiskGB = 5 } + if req.Containers[i].PortMappingCount < 2 { + req.Containers[i].PortMappingCount = 2 + } + if req.Containers[i].SnapshotLimit <= 0 { + req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit + } if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()}) return diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 941a434..5206931 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -56,44 +56,52 @@ type AuditLog struct { // OversellConfig controls host-level overselling behavior type OversellConfig struct { - CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell - RAMOvercommit int `json:"ram_overcommit"` // multiplier - DiskOvercommit int `json:"disk_overcommit"` // multiplier - KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging - Swappiness int `json:"swappiness"` // 0-100, lower = less swap + CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell + RAMOvercommit int `json:"ram_overcommit"` // multiplier + DiskOvercommit int `json:"disk_overcommit"` // multiplier + KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging + Swappiness int `json:"swappiness"` // 0-100, lower = less swap + SubUserSnapshotLimit int `json:"sub_user_snapshot_limit"` // legacy default for migrating old containers } // Container represents an LXC container configuration type Container struct { - ID int `json:"id"` - UUID string `json:"uuid"` - Name string `json:"name"` - LXCName string `json:"lxc_name,omitempty"` - Template string `json:"template"` - VCPU float64 `json:"vcpu"` - RAMMB int `json:"ram_mb"` - DiskGB int `json:"disk_gb"` - NetworkBWMbps int `json:"network_bw_mbps"` - MonthlyTrafficGB int `json:"monthly_traffic_gb"` - TrafficMode string `json:"traffic_mode"` // "total" or "in_out" - TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited - TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited - TrafficUsedRX int64 `json:"traffic_used_rx"` - TrafficUsedTX int64 `json:"traffic_used_tx"` - TrafficResetDate string `json:"traffic_reset_date"` - IOSpeedMBps int `json:"io_speed_mbps"` - Status string `json:"status"` - IP string `json:"ip"` - IPv6 string `json:"ipv6"` - IPv6PrefixLen int `json:"ipv6_prefix_len"` - IPv6Interface string `json:"ipv6_interface"` - VNCPort int `json:"vnc_port"` - SSHPort int `json:"ssh_port"` - SSHPassword string `json:"ssh_password"` - PortMappings []PortMapping `json:"port_mappings"` - PortMappingLimit int `json:"port_mapping_limit"` - CreatedAt string `json:"created_at"` - ExpiresAt string `json:"expires_at"` + ID int `json:"id"` + UUID string `json:"uuid"` + Name string `json:"name"` + LXCName string `json:"lxc_name,omitempty"` + Template string `json:"template"` + VCPU float64 `json:"vcpu"` + RAMMB int `json:"ram_mb"` + DiskGB int `json:"disk_gb"` + NetworkBWMbps int `json:"network_bw_mbps"` + MonthlyTrafficGB int `json:"monthly_traffic_gb"` + TrafficMode string `json:"traffic_mode"` // "total" or "in_out" + TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited + TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited + TrafficUsedRX int64 `json:"traffic_used_rx"` + TrafficUsedTX int64 `json:"traffic_used_tx"` + TrafficResetDate string `json:"traffic_reset_date"` + IOSpeedMBps int `json:"io_speed_mbps"` + Status string `json:"status"` + IP string `json:"ip"` + IPv6 string `json:"ipv6"` + IPv6PrefixLen int `json:"ipv6_prefix_len"` + IPv6Interface string `json:"ipv6_interface"` + VNCPort int `json:"vnc_port"` + SSHPort int `json:"ssh_port"` + SSHPassword string `json:"ssh_password"` + PortMappings []PortMapping `json:"port_mappings"` + PortMappingLimit int `json:"port_mapping_limit"` + SnapshotLimit int `json:"snapshot_limit"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"` + SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"` + SnapshotScheduleTime string `json:"snapshot_schedule_time"` + SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"` + SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"` + SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"` } // LxcName returns the internal LXC container name (ct-{id}) @@ -138,6 +146,18 @@ type SubUser struct { CreatedAt string `json:"created_at"` } +type Snapshot struct { + ID string `json:"id"` + ContainerID int `json:"container_id"` + ContainerName string `json:"container_name"` + LXCName string `json:"lxc_name"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` + Scheduled bool `json:"scheduled"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` +} + // ClicdConfig is the main configuration structure type ClicdConfig struct { AdminUser string `json:"admin_user"` @@ -157,11 +177,14 @@ type ClicdConfig struct { Tasks []SavedTask `json:"tasks"` LoginLogs []SavedLoginLog `json:"login_logs"` EnabledImages []string `json:"enabled_images"` + Snapshots []Snapshot `json:"snapshots"` } var configPath string var AppConfig *ClicdConfig +const DefaultSnapshotLimit = 3 + func getConfigPath() string { if configPath != "" { return configPath @@ -249,12 +272,14 @@ func InitConfig() (*ClicdConfig, error) { Tasks: []SavedTask{}, LoginLogs: []SavedLoginLog{}, Oversell: OversellConfig{ - CPUOvercommit: 4, - RAMOvercommit: 1, - DiskOvercommit: 2, - KSMEnabled: true, - Swappiness: 10, + CPUOvercommit: 4, + RAMOvercommit: 1, + DiskOvercommit: 2, + KSMEnabled: true, + Swappiness: 10, + SubUserSnapshotLimit: 3, }, + Snapshots: []Snapshot{}, } if err := SaveConfig(); err != nil { @@ -304,10 +329,22 @@ func InitConfig() (*ClicdConfig, error) { if AppConfig.Containers == nil { AppConfig.Containers = make([]Container, 0) } + if AppConfig.Snapshots == nil { + AppConfig.Snapshots = make([]Snapshot, 0) + } + if AppConfig.Oversell.SubUserSnapshotLimit <= 0 { + AppConfig.Oversell.SubUserSnapshotLimit = 3 + } changed := ensureContainerUUIDs() if ensureContainerPortMappingLimits() { changed = true } + if ensureContainerSnapshotLimits() { + changed = true + } + if ensureContainerSnapshotScheduleDefaults() { + changed = true + } if removeLegacyVNCMappings() { changed = true } @@ -320,6 +357,21 @@ func InitConfig() (*ClicdConfig, error) { return AppConfig, nil } +func ensureContainerSnapshotScheduleDefaults() bool { + changed := false + for i := range AppConfig.Containers { + if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleIntervalHours < 24 { + AppConfig.Containers[i].SnapshotScheduleIntervalHours = 24 + changed = true + } + if AppConfig.Containers[i].SnapshotScheduleEnabled && AppConfig.Containers[i].SnapshotScheduleTime == "" { + AppConfig.Containers[i].SnapshotScheduleTime = "03:00" + changed = true + } + } + return changed +} + func ensureContainerUUIDs() bool { changed := false used := make(map[string]bool) @@ -355,6 +407,35 @@ func ensureContainerPortMappingLimits() bool { return changed } +func ensureContainerSnapshotLimits() bool { + changed := false + legacyLimit := AppConfig.Oversell.SubUserSnapshotLimit + if legacyLimit <= 0 { + legacyLimit = DefaultSnapshotLimit + } + for i := range AppConfig.Containers { + if AppConfig.Containers[i].SnapshotLimit <= 0 { + AppConfig.Containers[i].SnapshotLimit = legacyLimit + changed = true + } + } + return changed +} + +func NormalizeSnapshotLimit(limit int) int { + if limit <= 0 { + return DefaultSnapshotLimit + } + return limit +} + +func ContainerSnapshotLimit(c *Container) int { + if c == nil { + return DefaultSnapshotLimit + } + return NormalizeSnapshotLimit(c.SnapshotLimit) +} + func removeLegacyVNCMappings() bool { changed := false for i := range AppConfig.Containers { @@ -408,6 +489,7 @@ func RemoveContainer(id int) bool { for i, c := range AppConfig.Containers { if c.ID == id { removeSubUserContainerAccess(c.Name) + removeContainerSnapshotMetadata(id) AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...) SaveConfig() return true @@ -416,6 +498,51 @@ func RemoveContainer(id int) bool { return false } +func AddSnapshot(snapshot Snapshot) { + AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot) + SaveConfig() +} + +func FindSnapshot(id string) *Snapshot { + for i := range AppConfig.Snapshots { + if AppConfig.Snapshots[i].ID == id { + return &AppConfig.Snapshots[i] + } + } + return nil +} + +func RemoveSnapshot(id string) bool { + for i := range AppConfig.Snapshots { + if AppConfig.Snapshots[i].ID == id { + AppConfig.Snapshots = append(AppConfig.Snapshots[:i], AppConfig.Snapshots[i+1:]...) + SaveConfig() + return true + } + } + return false +} + +func ContainerSnapshots(containerID int) []Snapshot { + result := make([]Snapshot, 0) + for _, snapshot := range AppConfig.Snapshots { + if snapshot.ContainerID == containerID { + result = append(result, snapshot) + } + } + return result +} + +func removeContainerSnapshotMetadata(containerID int) { + filtered := make([]Snapshot, 0, len(AppConfig.Snapshots)) + for _, snapshot := range AppConfig.Snapshots { + if snapshot.ContainerID != containerID { + filtered = append(filtered, snapshot) + } + } + AppConfig.Snapshots = filtered +} + func removeSubUserContainerAccess(containerName string) { if containerName == "" || len(AppConfig.SubUsers) == 0 { return diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index 89e4eba..3f47a86 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -216,6 +216,7 @@ type ContainerConfig struct { IOSpeedMBps int `json:"io_speed_mbps"` ExtraPorts []int `json:"extra_ports"` PortMappingCount int `json:"port_mapping_count"` + SnapshotLimit int `json:"snapshot_limit"` AssignIPv6 bool `json:"assign_ipv6"` ExpiresAt string `json:"expires_at"` } @@ -226,6 +227,12 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { if tmpl == nil { return fmt.Errorf("template not found: %s", cfg.TemplateID) } + if cfg.PortMappingCount < 2 { + cfg.PortMappingCount = 2 + } + if cfg.SnapshotLimit <= 0 { + cfg.SnapshotLimit = config.DefaultSnapshotLimit + } if !config.IsValidContainerName(cfg.Name) { return fmt.Errorf("invalid container name: %s", cfg.Name) @@ -351,6 +358,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error { SSHPassword: sshPassword, PortMappings: portMappings, PortMappingLimit: cfg.PortMappingCount, + SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit), CreatedAt: now, ExpiresAt: cfg.ExpiresAt, } @@ -1458,6 +1466,10 @@ func (m *Manager) DestroyContainer(id int) error { } return fmt.Errorf("container still exists after cleanup with status %s", status) } + snapshotDir := filepath.Join(snapshotBaseDir(), lxcName) + if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil { + os.RemoveAll(snapshotDir) + } if !config.RemoveContainer(id) { return fmt.Errorf("container destroyed but config entry was not removed: %d", id) @@ -1986,6 +1998,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) { Status: status, CreatedAt: time.Now().Format(time.RFC3339), PortMappingLimit: 2, + SnapshotLimit: config.DefaultSnapshotLimit, } if status == "running" { diff --git a/backend/internal/lxc/snapshot.go b/backend/internal/lxc/snapshot.go new file mode 100644 index 0000000..842c5e3 --- /dev/null +++ b/backend/internal/lxc/snapshot.go @@ -0,0 +1,349 @@ +package lxc + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "clicd/internal/config" +) + +var snapshotMu sync.Mutex + +func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) { + snapshotMu.Lock() + defer snapshotMu.Unlock() + + c := config.FindContainer(id) + if c == nil { + return config.Snapshot{}, fmt.Errorf("container not found: %d", id) + } + if scheduled && rotateLimit > 0 { + for { + existing := config.ContainerSnapshots(id) + if len(existing) < rotateLimit { + break + } + sortSnapshotsOldestFirst(existing) + if err := m.deleteSnapshotLocked(existing[0]); err != nil { + return config.Snapshot{}, err + } + } + } + + lxcName := c.LxcName() + containerDir := filepath.Join(m.LxcPath, lxcName) + if _, err := os.Stat(containerDir); err != nil { + return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err) + } + + now := time.Now() + snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000")) + snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, snapshotID) + if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil { + return config.Snapshot{}, err + } + if err := os.MkdirAll(snapshotDir, 0700); err != nil { + return config.Snapshot{}, err + } + + wasRunning, err := m.prepareContainerForColdCopy(id, lxcName, containerDir) + if err != nil { + os.RemoveAll(snapshotDir) + return config.Snapshot{}, err + } + if wasRunning { + defer func() { + if err := m.StartContainer(id); err != nil { + fmt.Printf("Warning: failed to restart %s after snapshot: %v\n", lxcName, err) + } + }() + } + + if err := copyTree(containerDir, snapshotDir); err != nil { + os.RemoveAll(snapshotDir) + return config.Snapshot{}, err + } + + snapshot := config.Snapshot{ + ID: snapshotID, + ContainerID: c.ID, + ContainerName: c.Name, + LXCName: lxcName, + CreatedAt: now.Format("2006-01-02 15:04:05"), + CreatedBy: createdBy, + Scheduled: scheduled, + Path: snapshotDir, + SizeBytes: dirSizeBytes(snapshotDir), + } + config.AddSnapshot(snapshot) + return snapshot, nil +} + +func (m *Manager) DeleteSnapshot(id string) error { + snapshotMu.Lock() + defer snapshotMu.Unlock() + + snapshot := config.FindSnapshot(id) + if snapshot == nil { + return fmt.Errorf("snapshot not found: %s", id) + } + return m.deleteSnapshotLocked(*snapshot) +} + +func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error { + if snapshot.Path != "" { + if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { + return err + } + if err := os.RemoveAll(snapshot.Path); err != nil { + return fmt.Errorf("failed to delete snapshot files: %v", err) + } + } + config.RemoveSnapshot(snapshot.ID) + return nil +} + +func (m *Manager) RestoreSnapshot(id string) error { + snapshotMu.Lock() + defer snapshotMu.Unlock() + + snapshot := config.FindSnapshot(id) + if snapshot == nil { + return fmt.Errorf("snapshot not found: %s", id) + } + if snapshot.Path == "" { + return fmt.Errorf("snapshot path is empty") + } + if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil { + return err + } + if _, err := os.Stat(snapshot.Path); err != nil { + return fmt.Errorf("snapshot files not found: %v", err) + } + + c := config.FindContainer(snapshot.ContainerID) + if c == nil { + return fmt.Errorf("container not found: %d", snapshot.ContainerID) + } + lxcName := c.LxcName() + containerDir := filepath.Join(m.LxcPath, lxcName) + if err := safePathUnder(containerDir, m.LxcPath); err != nil { + return err + } + + wasRunning, err := m.prepareContainerForColdCopy(c.ID, lxcName, containerDir) + if err != nil { + return err + } + + backupDir := filepath.Join(m.LxcPath, fmt.Sprintf(".%s-restore-backup-%d", lxcName, time.Now().UnixNano())) + if err := safePathUnder(backupDir, m.LxcPath); err != nil { + return err + } + if err := os.Rename(containerDir, backupDir); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to move current container aside: %v", err) + } + + if err := copyTree(snapshot.Path, containerDir); err != nil { + os.RemoveAll(containerDir) + _ = os.Rename(backupDir, containerDir) + return fmt.Errorf("failed to restore snapshot: %v", err) + } + _ = os.RemoveAll(backupDir) + + config.UpdateContainerStatus(c.ID, "stopped") + if wasRunning { + return m.StartContainer(c.ID) + } + return nil +} + +func (m *Manager) SetSnapshotSchedule(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) { + c := config.FindContainer(id) + if c == nil { + return nil, fmt.Errorf("container not found: %d", id) + } + if intervalHours < 24 { + return nil, fmt.Errorf("snapshot schedule interval cannot be less than 24 hours") + } + if _, err := parseScheduleClock(scheduleTime); err != nil { + return nil, err + } + c.SnapshotScheduleEnabled = enabled + c.SnapshotScheduleIntervalHours = intervalHours + c.SnapshotScheduleTime = scheduleTime + c.SnapshotScheduleCreatedBy = createdBy + if enabled { + c.SnapshotScheduleNextRun = nextSnapshotRun(time.Now(), intervalHours, scheduleTime).Format(time.RFC3339) + } else { + c.SnapshotScheduleNextRun = "" + } + if err := config.SaveConfig(); err != nil { + return nil, err + } + return c, nil +} + +func (m *Manager) StartSnapshotScheduler() { + go func() { + m.runDueSnapshotSchedules() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + m.runDueSnapshotSchedules() + } + }() +} + +func (m *Manager) runDueSnapshotSchedules() { + now := time.Now() + containers := append([]config.Container(nil), config.AppConfig.Containers...) + for _, c := range containers { + if !c.SnapshotScheduleEnabled { + continue + } + nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun) + if err != nil || c.SnapshotScheduleNextRun == "" { + nextRun = now + } + if now.Before(nextRun) { + continue + } + createdBy := c.SnapshotScheduleCreatedBy + if createdBy == "" { + createdBy = "admin" + } + rotateLimit := 0 + if strings.HasPrefix(createdBy, "user:") { + rotateLimit = config.ContainerSnapshotLimit(&c) + } + if _, err := m.CreateSnapshot(c.ID, createdBy, true, rotateLimit); err != nil { + fmt.Printf("Warning: scheduled snapshot failed for %s: %v\n", c.Name, err) + continue + } + if current := config.FindContainer(c.ID); current != nil { + interval := current.SnapshotScheduleIntervalHours + if interval < 24 { + interval = 24 + } + next := nextRun.Add(time.Duration(interval) * time.Hour) + for !next.After(now) { + next = next.Add(time.Duration(interval) * time.Hour) + } + current.SnapshotScheduleLastRun = now.Format(time.RFC3339) + current.SnapshotScheduleNextRun = next.Format(time.RFC3339) + config.SaveConfig() + } + } +} + +func parseScheduleClock(value string) (time.Duration, error) { + parts := strings.Split(value, ":") + if len(parts) != 2 { + return 0, fmt.Errorf("snapshot schedule time must be HH:MM") + } + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return 0, fmt.Errorf("snapshot schedule hour must be 00-23") + } + minute, err := strconv.Atoi(parts[1]) + if err != nil || minute < 0 || minute > 59 { + return 0, fmt.Errorf("snapshot schedule minute must be 00-59") + } + return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil +} + +func nextSnapshotRun(from time.Time, intervalHours int, scheduleTime string) time.Time { + clock, err := parseScheduleClock(scheduleTime) + if err != nil { + clock = 3 * time.Hour + } + midnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location()) + next := midnight.Add(clock) + interval := time.Duration(intervalHours) * time.Hour + for !next.After(from) { + next = next.Add(interval) + } + return next +} + +func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerDir string) (bool, error) { + status, _ := m.GetContainerStatus(lxcName) + wasRunning := status == "running" + if wasRunning { + if err := m.StopContainer(id); err != nil { + return false, err + } + time.Sleep(time.Second) + } else if c := config.FindContainer(id); c != nil { + m.CleanPortMappings(id) + m.cleanupBandwidthLimit(c.LxcName()) + } + rootfs := filepath.Join(containerDir, "rootfs") + exec.Command("umount", "-R", "-l", rootfs).Run() + m.detachContainerMounts(containerDir) + m.detachContainerLoopDevices(containerDir) + return wasRunning, nil +} + +func snapshotBaseDir() string { + return filepath.Join(config.AppConfig.DataDir, "snapshots") +} + +func copyTree(src string, dst string) error { + if err := os.MkdirAll(dst, 0700); err != nil { + return err + } + output, err := exec.Command("cp", "-a", "--sparse=always", "--reflink=auto", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput() + if err != nil { + output, err = exec.Command("cp", "-a", "--sparse=always", src+string(os.PathSeparator)+".", dst+string(os.PathSeparator)).CombinedOutput() + if err != nil { + return fmt.Errorf("cp failed: %v, output: %s", err, string(output)) + } + } + return nil +} + +func dirSizeBytes(path string) int64 { + out, err := exec.Command("du", "-s", "-B1", path).Output() + if err != nil { + return 0 + } + parts := strings.Fields(string(out)) + if len(parts) == 0 { + return 0 + } + var size int64 + fmt.Sscanf(parts[0], "%d", &size) + return size +} + +func safePathUnder(path string, base string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + absBase, err := filepath.Abs(base) + if err != nil { + return err + } + if absPath == absBase || strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) { + return nil + } + return fmt.Errorf("refusing unsafe path: %s", absPath) +} + +func sortSnapshotsOldestFirst(snapshots []config.Snapshot) { + sort.SliceStable(snapshots, func(i, j int) bool { + ti, _ := time.Parse("2006-01-02 15:04:05", snapshots[i].CreatedAt) + tj, _ := time.Parse("2006-01-02 15:04:05", snapshots[j].CreatedAt) + return ti.Before(tj) + }) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index c31f084..bcad835 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -50,6 +50,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard))) mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo))) + mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell))) mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus))) diff --git a/backend/main.go b/backend/main.go index 1894f4b..366e0bd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -60,6 +60,9 @@ func main() { // Start usage monitor (computes CPU/network/disk rates every 5s) manager.StartUsageMonitor() + // Start scheduled snapshot scanner. + manager.StartSnapshotScheduler() + // Clean up stale container configs (LXC dir was deleted but config remains) config.CleanStaleContainers() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 37a4217..6163185 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import AuditLogs from './pages/AuditLogs' import ApiIntegration from './pages/ApiIntegration' import Settings from './pages/Settings' import ImageManagement from './pages/ImageManagement' +import Snapshots from './pages/Snapshots' import Layout from './components/Layout' function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -57,6 +58,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index 675ce06..81ea80a 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -24,6 +24,7 @@ const defaultForm: CreateContainerRequest = { io_speed_mbps: 0, extra_ports: [], port_mapping_count: 2, + snapshot_limit: 3, assign_ipv6: false, expires_at: '', } @@ -94,7 +95,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre const containers: CreateContainerRequest[] = [] for (let i = 0; i < batchCount; i++) { const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name - containers.push({ ...boundedForm, name, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), extra_ports: [] }) + containers.push({ + ...boundedForm, + name, + port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), + snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3), + extra_ports: [], + }) } setLoading(true) @@ -248,6 +255,15 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre + + setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })} + /> + +
@@ -325,6 +341,7 @@ function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB vcpu: clampVCPU(form.vcpu, maxVCPU), ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512), disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10), + snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3), } } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 809b4c5..a3efd34 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Code2, + Camera, LayoutDashboard, LogOut, Package, @@ -31,6 +32,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { const isImagesPage = location.pathname.startsWith('/images') const isOversellPage = location.pathname.startsWith('/oversell') + const isSnapshotsPage = location.pathname.startsWith('/snapshots') const isAuditLogsPage = location.pathname.startsWith('/audit-logs') const isApiIntegrationPage = location.pathname.startsWith('/api-integration') const isSecurityPage = location.pathname.startsWith('/security') @@ -136,6 +138,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { {!collapsed && 安全告警} + + + +
+ } + > +
+
+
+ 快照数量: + + {snapshots.length} + +
+
+ 子用户配额: + {snapshotQuota} + {!isSubUser && ( + + )} +
+
+ 定时状态: + + {snapshotSchedule?.enabled ? `已开启,每 ${formatScheduleInterval(snapshotSchedule.interval_hours || 24)},${snapshotSchedule.time || '03:00'} 执行` : '未开启'} + +
+ {snapshotSchedule?.next_run && ( +
下次执行:{formatDateTime(snapshotSchedule.next_run)}
+ )} +
+ + {editingSnapshotQuota && !isSubUser && ( +
+ + setSnapshotQuotaDraft(Math.max(1, Math.round(Number(event.target.value) || 1)))} + className="w-44 px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black" + /> + +
+ + +
+
+ )} + + +
+ + )} + + {showSnapshotSchedule && ( + setShowSnapshotSchedule(false)}> +
+ + + + + setSnapshotScheduleDraft({ ...snapshotScheduleDraft, time: e.target.value || '03:00' })} + className={inputClass} + /> + +
+ {`每 ${formatScheduleInterval(snapshotScheduleDraft.intervalHours)} 在 ${snapshotScheduleDraft.time || '03:00'} 执行。`} +
+
+ {snapshotSchedule?.enabled ? ( + + ) :
} +
+ + +
+
+
+ + )} + {showNat && ( { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={ !isSubUser && canAddMapping && !showNatAdd && ( @@ -1229,6 +1538,65 @@ function PlainRow({ label, value, mono = false, copyValue, onCopy, children }: { ) } +function SnapshotTable({ snapshots, busy, onRestore, onDelete }: { + snapshots: Snapshot[] + busy: string + onRestore: (snapshot: Snapshot) => void + onDelete: (snapshot: Snapshot) => void +}) { + if (snapshots.length === 0) { + return

暂无快照

+ } + + return ( +
+ + + + 快照时间 + 类型 + 创建者 + 大小 + + + + + {snapshots.map((snapshot) => ( + + + + + + + + ))} + +
操作
{snapshot.created_at} + + {snapshot.scheduled ? '定时' : '手动'} + + {snapshot.created_by || '-'}{formatBytes(snapshot.size_bytes || 0)} +
+ + +
+
+
+ ) +} + function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false, isSubUser = false }: { mappings: PortMapping[]; publicHost: string; onEdit: (pm: PortMapping, index: number) => void; onDelete: (index: number) => void; compact?: boolean; isSubUser?: boolean }) { if (mappings.length === 0) { return

暂无端口映射

@@ -1389,6 +1757,19 @@ function formatExpiration(value?: string): string { return value.length >= 10 ? value.slice(0, 10) : value } +function formatDateTime(value?: string): string { + if (!value) return '-' + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + return parsed.toLocaleString() +} + +function formatScheduleInterval(hours: number): string { + if (hours === 24) return '1 天' + if (hours % 24 === 0) return `${hours / 24} 天` + return `${hours} 小时` +} + function formatRate(bytesPerSecond: number): string { if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 78c0c60..6c3303a 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -538,8 +538,15 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer { ssh_password: '', port_mappings: [], port_mapping_limit: 2, + snapshot_limit: cfg.snapshot_limit || 3, created_at: '', expires_at: cfg.expires_at, + snapshot_schedule_enabled: false, + snapshot_schedule_interval_hours: 24, + snapshot_schedule_time: '03:00', + snapshot_schedule_last_run: '', + snapshot_schedule_next_run: '', + snapshot_schedule_created_by: '', isPlaceholder: true, } } diff --git a/frontend/src/pages/Snapshots.tsx b/frontend/src/pages/Snapshots.tsx new file mode 100644 index 0000000..4b3935b --- /dev/null +++ b/frontend/src/pages/Snapshots.tsx @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useState } from 'react' +import { Camera, RefreshCw, Server } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { getSnapshots, Snapshot } from '../services/api' + +export default function Snapshots() { + const navigate = useNavigate() + const [snapshots, setSnapshots] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const fetchData = useCallback(async () => { + try { + const res = await getSnapshots() + setSnapshots(res.data.data || []) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+
+

快照管理

+

全局快照列表,共 {snapshots.length} 个

+
+ +
+ +
+ {snapshots.length === 0 ? ( +
+
+ +
+
暂无快照
+
+ ) : ( + + + + + + + + + + + + + {snapshots.map((snapshot) => ( + + + + + + + + + ))} + +
容器LXC 名称快照时间类型创建者大小
+ + {snapshot.lxc_name}{snapshot.created_at} + + {snapshot.scheduled ? '定时' : '手动'} + + {snapshot.created_by || '-'}{formatBytes(snapshot.size_bytes || 0)}
+ )} +
+
+ ) +} + +function formatBytes(bytes: number): string { + if (!bytes) return '-' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB` +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7414177..dfe408a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -71,8 +71,15 @@ export interface Container { ssh_password: string port_mappings: PortMapping[] port_mapping_limit: number + snapshot_limit: number created_at: string expires_at: string + snapshot_schedule_enabled: boolean + snapshot_schedule_interval_hours: number + snapshot_schedule_time: string + snapshot_schedule_last_run: string + snapshot_schedule_next_run: string + snapshot_schedule_created_by: string } export interface Template { @@ -100,6 +107,7 @@ export interface CreateContainerRequest { io_speed_mbps: number extra_ports: number[] port_mapping_count: number + snapshot_limit: number assign_ipv6: boolean expires_at: string } @@ -354,6 +362,62 @@ export const getOversellStatus = () => export const reclaimMemory = () => api.post>('/oversell/reclaim') +// Snapshots +export interface Snapshot { + id: string + container_id: number + container_name: string + lxc_name: string + created_at: string + created_by: string + scheduled: boolean + path: string + size_bytes: number +} + +export interface SnapshotSchedule { + enabled: boolean + interval_hours: number + time: string + last_run: string + next_run: string + created_by: string +} + +export interface ContainerSnapshotsResponse { + snapshots: Snapshot[] + quota: number + schedule: SnapshotSchedule +} + +export const getSnapshots = () => + api.get>('/snapshots') + +export const getContainerSnapshots = (id: ContainerIdentifier) => + api.get>(`/containers/${id}/snapshots`) + +export const createContainerSnapshot = (id: ContainerIdentifier) => + api.post>(`/containers/${id}/snapshots`, {}, { timeout: 600000 }) + +export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) => + api.delete(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 }) + +export const restoreContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) => + api.post(`/containers/${id}/snapshots/${snapshotId}/restore`, {}, { timeout: 600000 }) + +export const updateSnapshotSchedule = (id: ContainerIdentifier, enabled: boolean, intervalHours: number, time: string) => + api.post>( + `/containers/${id}/snapshots/schedule`, + { enabled, interval_hours: intervalHours, time }, + { timeout: 600000 } + ) + +export const updateSnapshotQuota = (id: ContainerIdentifier, snapshotLimit: number) => + api.put>( + `/containers/${id}/snapshots/quota`, + { snapshot_limit: snapshotLimit } + ) + // WebSSH URL generator export const getWebSSHUrl = (containerName: string) => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'