mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
添加了快照功能支持,支持定时快照和回滚快照
This commit is contained in:
@@ -69,6 +69,8 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
updateExpiry(w, r, id)
|
updateExpiry(w, r, id)
|
||||||
case action == "ipv6" && r.Method == http.MethodPost:
|
case action == "ipv6" && r.Method == http.MethodPost:
|
||||||
assignIPv6(w, r, id)
|
assignIPv6(w, r, id)
|
||||||
|
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||||
|
handleContainerSnapshots(w, r, id, action)
|
||||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||||
addPortMapping(w, r, id)
|
addPortMapping(w, r, id)
|
||||||
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
|
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"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.SnapshotLimit <= 0 {
|
||||||
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
|
}
|
||||||
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func updateOversell(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.SubUserSnapshotLimit <= 0 {
|
||||||
|
cfg.SubUserSnapshotLimit = 3
|
||||||
|
}
|
||||||
|
|
||||||
// Apply KSM
|
// Apply KSM
|
||||||
if cfg.KSMEnabled {
|
if cfg.KSMEnabled {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -377,6 +377,12 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
|||||||
switch {
|
switch {
|
||||||
case action == "usage" || action == "traffic" || action == "random-port":
|
case action == "usage" || action == "traffic" || action == "random-port":
|
||||||
return method == http.MethodGet
|
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":
|
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
|
||||||
return method == http.MethodPost
|
return method == http.MethodPost
|
||||||
case strings.HasPrefix(action, "port-mappings/"):
|
case strings.HasPrefix(action, "port-mappings/"):
|
||||||
|
|||||||
@@ -504,6 +504,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Containers[i].DiskGB < 1 {
|
if req.Containers[i].DiskGB < 1 {
|
||||||
req.Containers[i].DiskGB = 5
|
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 {
|
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()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type OversellConfig struct {
|
|||||||
DiskOvercommit int `json:"disk_overcommit"` // multiplier
|
DiskOvercommit int `json:"disk_overcommit"` // multiplier
|
||||||
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
|
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
|
||||||
Swappiness int `json:"swappiness"` // 0-100, lower = less swap
|
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
|
// Container represents an LXC container configuration
|
||||||
@@ -92,8 +93,15 @@ type Container struct {
|
|||||||
SSHPassword string `json:"ssh_password"`
|
SSHPassword string `json:"ssh_password"`
|
||||||
PortMappings []PortMapping `json:"port_mappings"`
|
PortMappings []PortMapping `json:"port_mappings"`
|
||||||
PortMappingLimit int `json:"port_mapping_limit"`
|
PortMappingLimit int `json:"port_mapping_limit"`
|
||||||
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
ExpiresAt string `json:"expires_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})
|
// LxcName returns the internal LXC container name (ct-{id})
|
||||||
@@ -138,6 +146,18 @@ type SubUser struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
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
|
// ClicdConfig is the main configuration structure
|
||||||
type ClicdConfig struct {
|
type ClicdConfig struct {
|
||||||
AdminUser string `json:"admin_user"`
|
AdminUser string `json:"admin_user"`
|
||||||
@@ -157,11 +177,14 @@ type ClicdConfig struct {
|
|||||||
Tasks []SavedTask `json:"tasks"`
|
Tasks []SavedTask `json:"tasks"`
|
||||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||||
EnabledImages []string `json:"enabled_images"`
|
EnabledImages []string `json:"enabled_images"`
|
||||||
|
Snapshots []Snapshot `json:"snapshots"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configPath string
|
var configPath string
|
||||||
var AppConfig *ClicdConfig
|
var AppConfig *ClicdConfig
|
||||||
|
|
||||||
|
const DefaultSnapshotLimit = 3
|
||||||
|
|
||||||
func getConfigPath() string {
|
func getConfigPath() string {
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
return configPath
|
return configPath
|
||||||
@@ -254,7 +277,9 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
DiskOvercommit: 2,
|
DiskOvercommit: 2,
|
||||||
KSMEnabled: true,
|
KSMEnabled: true,
|
||||||
Swappiness: 10,
|
Swappiness: 10,
|
||||||
|
SubUserSnapshotLimit: 3,
|
||||||
},
|
},
|
||||||
|
Snapshots: []Snapshot{},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
@@ -304,10 +329,22 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
if AppConfig.Containers == nil {
|
if AppConfig.Containers == nil {
|
||||||
AppConfig.Containers = make([]Container, 0)
|
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()
|
changed := ensureContainerUUIDs()
|
||||||
if ensureContainerPortMappingLimits() {
|
if ensureContainerPortMappingLimits() {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if ensureContainerSnapshotLimits() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if ensureContainerSnapshotScheduleDefaults() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if removeLegacyVNCMappings() {
|
if removeLegacyVNCMappings() {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -320,6 +357,21 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
return AppConfig, nil
|
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 {
|
func ensureContainerUUIDs() bool {
|
||||||
changed := false
|
changed := false
|
||||||
used := make(map[string]bool)
|
used := make(map[string]bool)
|
||||||
@@ -355,6 +407,35 @@ func ensureContainerPortMappingLimits() bool {
|
|||||||
return changed
|
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 {
|
func removeLegacyVNCMappings() bool {
|
||||||
changed := false
|
changed := false
|
||||||
for i := range AppConfig.Containers {
|
for i := range AppConfig.Containers {
|
||||||
@@ -408,6 +489,7 @@ func RemoveContainer(id int) bool {
|
|||||||
for i, c := range AppConfig.Containers {
|
for i, c := range AppConfig.Containers {
|
||||||
if c.ID == id {
|
if c.ID == id {
|
||||||
removeSubUserContainerAccess(c.Name)
|
removeSubUserContainerAccess(c.Name)
|
||||||
|
removeContainerSnapshotMetadata(id)
|
||||||
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
|
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
return true
|
return true
|
||||||
@@ -416,6 +498,51 @@ func RemoveContainer(id int) bool {
|
|||||||
return false
|
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) {
|
func removeSubUserContainerAccess(containerName string) {
|
||||||
if containerName == "" || len(AppConfig.SubUsers) == 0 {
|
if containerName == "" || len(AppConfig.SubUsers) == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ type ContainerConfig struct {
|
|||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
@@ -226,6 +227,12 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
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) {
|
if !config.IsValidContainerName(cfg.Name) {
|
||||||
return fmt.Errorf("invalid container name: %s", cfg.Name)
|
return fmt.Errorf("invalid container name: %s", cfg.Name)
|
||||||
@@ -351,6 +358,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
SSHPassword: sshPassword,
|
SSHPassword: sshPassword,
|
||||||
PortMappings: portMappings,
|
PortMappings: portMappings,
|
||||||
PortMappingLimit: cfg.PortMappingCount,
|
PortMappingLimit: cfg.PortMappingCount,
|
||||||
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
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)
|
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) {
|
if !config.RemoveContainer(id) {
|
||||||
return fmt.Errorf("container destroyed but config entry was not removed: %d", 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,
|
Status: status,
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
PortMappingLimit: 2,
|
PortMappingLimit: 2,
|
||||||
|
SnapshotLimit: config.DefaultSnapshotLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == "running" {
|
if status == "running" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
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/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||||
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
|
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
|
||||||
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))
|
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ func main() {
|
|||||||
// Start usage monitor (computes CPU/network/disk rates every 5s)
|
// Start usage monitor (computes CPU/network/disk rates every 5s)
|
||||||
manager.StartUsageMonitor()
|
manager.StartUsageMonitor()
|
||||||
|
|
||||||
|
// Start scheduled snapshot scanner.
|
||||||
|
manager.StartSnapshotScheduler()
|
||||||
|
|
||||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||||
config.CleanStaleContainers()
|
config.CleanStaleContainers()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AuditLogs from './pages/AuditLogs'
|
|||||||
import ApiIntegration from './pages/ApiIntegration'
|
import ApiIntegration from './pages/ApiIntegration'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
import ImageManagement from './pages/ImageManagement'
|
import ImageManagement from './pages/ImageManagement'
|
||||||
|
import Snapshots from './pages/Snapshots'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -57,6 +58,7 @@ function App() {
|
|||||||
<Route path="container/:id" element={<ContainerDetail />} />
|
<Route path="container/:id" element={<ContainerDetail />} />
|
||||||
<Route path="oversell" element={<Oversell />} />
|
<Route path="oversell" element={<Oversell />} />
|
||||||
<Route path="security" element={<Security />} />
|
<Route path="security" element={<Security />} />
|
||||||
|
<Route path="snapshots" element={<Snapshots />} />
|
||||||
<Route path="audit-logs" element={<AuditLogs />} />
|
<Route path="audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="api-integration" element={<ApiIntegration />} />
|
<Route path="api-integration" element={<ApiIntegration />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
io_speed_mbps: 0,
|
io_speed_mbps: 0,
|
||||||
extra_ports: [],
|
extra_ports: [],
|
||||||
port_mapping_count: 2,
|
port_mapping_count: 2,
|
||||||
|
snapshot_limit: 3,
|
||||||
assign_ipv6: false,
|
assign_ipv6: false,
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
const containers: CreateContainerRequest[] = []
|
const containers: CreateContainerRequest[] = []
|
||||||
for (let i = 0; i < batchCount; i++) {
|
for (let i = 0; i < batchCount; i++) {
|
||||||
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name
|
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)
|
setLoading(true)
|
||||||
@@ -248,6 +255,15 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="子用户快照上限">
|
||||||
|
<NumberInput
|
||||||
|
value={form.snapshot_limit}
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label="到期时间">
|
<Field label="到期时间">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
@@ -325,6 +341,7 @@ function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB
|
|||||||
vcpu: clampVCPU(form.vcpu, maxVCPU),
|
vcpu: clampVCPU(form.vcpu, maxVCPU),
|
||||||
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
|
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
|
||||||
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
|
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
|
||||||
|
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Code2,
|
Code2,
|
||||||
|
Camera,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Package,
|
Package,
|
||||||
@@ -31,6 +32,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
|
|
||||||
const isImagesPage = location.pathname.startsWith('/images')
|
const isImagesPage = location.pathname.startsWith('/images')
|
||||||
const isOversellPage = location.pathname.startsWith('/oversell')
|
const isOversellPage = location.pathname.startsWith('/oversell')
|
||||||
|
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||||
const isSecurityPage = location.pathname.startsWith('/security')
|
const isSecurityPage = location.pathname.startsWith('/security')
|
||||||
@@ -136,6 +138,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
{!collapsed && <span>安全告警</span>}
|
{!collapsed && <span>安全告警</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/snapshots')}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
isSnapshotsPage
|
||||||
|
? 'bg-black text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
{!collapsed && <span>快照管理</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/audit-logs')}
|
onClick={() => navigate('/audit-logs')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Camera,
|
||||||
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
Cpu,
|
Cpu,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
@@ -29,9 +31,12 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
ContainerUsage,
|
ContainerUsage,
|
||||||
createSubUser,
|
createSubUser,
|
||||||
|
createContainerSnapshot,
|
||||||
deleteContainer,
|
deleteContainer,
|
||||||
|
deleteContainerSnapshot,
|
||||||
deletePortMapping,
|
deletePortMapping,
|
||||||
getContainer,
|
getContainer,
|
||||||
|
getContainerSnapshots,
|
||||||
getContainerUsage,
|
getContainerUsage,
|
||||||
getHostInfo,
|
getHostInfo,
|
||||||
getTrafficInfo,
|
getTrafficInfo,
|
||||||
@@ -44,8 +49,13 @@ import {
|
|||||||
restartContainer,
|
restartContainer,
|
||||||
startContainer,
|
startContainer,
|
||||||
stopContainer,
|
stopContainer,
|
||||||
|
Snapshot,
|
||||||
|
SnapshotSchedule,
|
||||||
Template,
|
Template,
|
||||||
updateContainerExpiry,
|
updateContainerExpiry,
|
||||||
|
updateSnapshotQuota,
|
||||||
|
updateSnapshotSchedule,
|
||||||
|
restoreContainerSnapshot,
|
||||||
resetTraffic,
|
resetTraffic,
|
||||||
updateTrafficLimit,
|
updateTrafficLimit,
|
||||||
updateResourceLimit,
|
updateResourceLimit,
|
||||||
@@ -125,6 +135,15 @@ export default function ContainerDetail() {
|
|||||||
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
|
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
|
||||||
const [savingResource, setSavingResource] = useState(false)
|
const [savingResource, setSavingResource] = useState(false)
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [showSnapshots, setShowSnapshots] = useState(false)
|
||||||
|
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||||
|
const [snapshotQuota, setSnapshotQuota] = useState(3)
|
||||||
|
const [snapshotQuotaDraft, setSnapshotQuotaDraft] = useState(3)
|
||||||
|
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
||||||
|
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
||||||
|
const [snapshotBusy, setSnapshotBusy] = useState('')
|
||||||
|
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||||
|
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||||
|
|
||||||
const fetchContainer = useCallback(async () => {
|
const fetchContainer = useCallback(async () => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
@@ -142,6 +161,21 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}, [containerIdentifier, isSubUser])
|
}, [containerIdentifier, isSubUser])
|
||||||
|
|
||||||
|
const fetchSnapshots = useCallback(async () => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
try {
|
||||||
|
const res = await getContainerSnapshots(containerIdentifier)
|
||||||
|
const data = res.data.data
|
||||||
|
const quota = data?.quota || container?.snapshot_limit || 3
|
||||||
|
setSnapshots(data?.snapshots || [])
|
||||||
|
setSnapshotQuota(quota)
|
||||||
|
setSnapshotQuotaDraft(quota)
|
||||||
|
setSnapshotSchedule(data?.schedule || null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch snapshots:', err)
|
||||||
|
}
|
||||||
|
}, [containerIdentifier, container?.snapshot_limit])
|
||||||
|
|
||||||
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
||||||
if (!containerIdentifier || !currentContainer) return
|
if (!containerIdentifier || !currentContainer) return
|
||||||
|
|
||||||
@@ -198,6 +232,10 @@ export default function ContainerDetail() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [fetchUsage])
|
}, [fetchUsage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSnapshots) fetchSnapshots()
|
||||||
|
}, [showSnapshots, fetchSnapshots])
|
||||||
|
|
||||||
// Poll task status for this container
|
// Poll task status for this container
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
@@ -478,6 +516,108 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateSnapshot = async () => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||||
|
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (container?.status === 'running') {
|
||||||
|
const confirmed = await dialog.confirm(
|
||||||
|
'拍摄快照',
|
||||||
|
`拍摄快照需要先关机,完成后会自动重启容器 ${container.name}。是否继续?`
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
setSnapshotBusy('create')
|
||||||
|
try {
|
||||||
|
await createContainerSnapshot(containerIdentifier)
|
||||||
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('创建快照失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSnapshotSchedule = () => {
|
||||||
|
setSnapshotScheduleDraft({
|
||||||
|
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||||
|
time: snapshotSchedule?.time || '03:00',
|
||||||
|
})
|
||||||
|
setShowSnapshotSchedule(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSnapshotSchedule = async (enabled: boolean) => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
const intervalHours = snapshotScheduleDraft.intervalHours
|
||||||
|
const scheduleTime = snapshotScheduleDraft.time || '03:00'
|
||||||
|
if (enabled && intervalHours < 24) {
|
||||||
|
await dialog.alert('参数错误', '自动快照周期最低是 1 天一次。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSnapshotBusy('schedule')
|
||||||
|
try {
|
||||||
|
await updateSnapshotSchedule(containerIdentifier, enabled, intervalHours, scheduleTime)
|
||||||
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
|
setShowSnapshotSchedule(false)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('定时快照失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSnapshotQuota = async () => {
|
||||||
|
if (!containerIdentifier || isSubUser) return
|
||||||
|
const nextQuota = Math.max(1, Math.round(snapshotQuotaDraft || 1))
|
||||||
|
setSnapshotBusy('quota')
|
||||||
|
try {
|
||||||
|
await updateSnapshotQuota(containerIdentifier, nextQuota)
|
||||||
|
setSnapshotQuota(nextQuota)
|
||||||
|
setSnapshotQuotaDraft(nextQuota)
|
||||||
|
setEditingSnapshotQuota(false)
|
||||||
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('保存快照配额失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSnapshot = async (snapshot: Snapshot) => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return
|
||||||
|
setSnapshotBusy(snapshot.id)
|
||||||
|
try {
|
||||||
|
await deleteContainerSnapshot(containerIdentifier, snapshot.id)
|
||||||
|
await fetchSnapshots()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('删除快照失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestoreSnapshot = async (snapshot: Snapshot) => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return
|
||||||
|
setSnapshotBusy(snapshot.id)
|
||||||
|
try {
|
||||||
|
await restoreContainerSnapshot(containerIdentifier, snapshot.id)
|
||||||
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
await dialog.alert('恢复快照失败', error.response?.data?.message || '请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
setSnapshotBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyText = async (text: string) => {
|
const copyText = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
await copyText(text)
|
await copyText(text)
|
||||||
@@ -641,6 +781,10 @@ export default function ContainerDetail() {
|
|||||||
NAT 管理
|
NAT 管理
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</>
|
</>
|
||||||
|
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
快照
|
||||||
|
</ActionButton>
|
||||||
{!isSubUser && (
|
{!isSubUser && (
|
||||||
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
|
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
@@ -846,6 +990,171 @@ export default function ContainerDetail() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showSnapshots && (
|
||||||
|
<Modal
|
||||||
|
title="快照"
|
||||||
|
onClose={() => {
|
||||||
|
setShowSnapshots(false)
|
||||||
|
setEditingSnapshotQuota(false)
|
||||||
|
}}
|
||||||
|
wide
|
||||||
|
extra={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={openSnapshotSchedule}
|
||||||
|
disabled={!!snapshotBusy}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
|
||||||
|
snapshotSchedule?.enabled
|
||||||
|
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||||
|
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{snapshotBusy === 'schedule' ? '处理中...' : snapshotSchedule?.enabled ? '定时设置' : '定时快照'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateSnapshot}
|
||||||
|
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{snapshotBusy === 'create' ? '创建中...' : '新建快照'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
|
||||||
|
<div>
|
||||||
|
快照数量:
|
||||||
|
<span className="font-mono text-gray-900">
|
||||||
|
{snapshots.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>子用户配额:</span>
|
||||||
|
<span className="font-mono text-gray-900">{snapshotQuota}</span>
|
||||||
|
{!isSubUser && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSnapshotQuotaDraft(snapshotQuota)
|
||||||
|
setEditingSnapshotQuota((value) => !value)
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-gray-300 bg-white px-2 py-1 text-[11px] text-gray-700 hover:bg-gray-50"
|
||||||
|
disabled={snapshotBusy === 'quota'}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
修改
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
定时状态:
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{snapshotSchedule?.enabled ? `已开启,每 ${formatScheduleInterval(snapshotSchedule.interval_hours || 24)},${snapshotSchedule.time || '03:00'} 执行` : '未开启'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{snapshotSchedule?.next_run && (
|
||||||
|
<div>下次执行:<span className="font-mono text-gray-900">{formatDateTime(snapshotSchedule.next_run)}</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingSnapshotQuota && !isSubUser && (
|
||||||
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||||
|
<Field label="子用户每台容器快照上限">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
value={snapshotQuotaDraft}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex gap-2 pb-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingSnapshotQuota(false)
|
||||||
|
setSnapshotQuotaDraft(snapshotQuota)
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"
|
||||||
|
disabled={snapshotBusy === 'quota'}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveSnapshotQuota}
|
||||||
|
disabled={snapshotBusy === 'quota'}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{snapshotBusy === 'quota' ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SnapshotTable
|
||||||
|
snapshots={snapshots}
|
||||||
|
busy={snapshotBusy}
|
||||||
|
onRestore={handleRestoreSnapshot}
|
||||||
|
onDelete={handleDeleteSnapshot}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSnapshotSchedule && (
|
||||||
|
<Modal title="定时快照" onClose={() => setShowSnapshotSchedule(false)}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="自动快照周期">
|
||||||
|
<select
|
||||||
|
value={snapshotScheduleDraft.intervalHours}
|
||||||
|
onChange={(e) => setSnapshotScheduleDraft({ ...snapshotScheduleDraft, intervalHours: Number(e.target.value) })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value={24}>1 天</option>
|
||||||
|
<option value={72}>3 天</option>
|
||||||
|
<option value={168}>7 天</option>
|
||||||
|
<option value={336}>14 天</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="执行时间">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={snapshotScheduleDraft.time}
|
||||||
|
onChange={(e) => setSnapshotScheduleDraft({ ...snapshotScheduleDraft, time: e.target.value || '03:00' })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
||||||
|
{`每 ${formatScheduleInterval(snapshotScheduleDraft.intervalHours)} 在 ${snapshotScheduleDraft.time || '03:00'} 执行。`}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 pt-2">
|
||||||
|
{snapshotSchedule?.enabled ? (
|
||||||
|
<button
|
||||||
|
onClick={() => saveSnapshotSchedule(false)}
|
||||||
|
disabled={snapshotBusy === 'schedule'}
|
||||||
|
className="px-4 py-2 text-sm text-red-600 border border-red-200 rounded-md hover:bg-red-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
关闭定时
|
||||||
|
</button>
|
||||||
|
) : <div />}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setShowSnapshotSchedule(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md">取消</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSnapshotSchedule(true)}
|
||||||
|
disabled={snapshotBusy === 'schedule'}
|
||||||
|
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{snapshotBusy === 'schedule' ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
{showNat && (
|
{showNat && (
|
||||||
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={
|
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={
|
||||||
!isSubUser && canAddMapping && !showNatAdd && (
|
!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 <p className="rounded-lg border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-400">暂无快照</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||||
|
<table className="w-full min-w-[760px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<TableHead>快照时间</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>创建者</TableHead>
|
||||||
|
<TableHead>大小</TableHead>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{snapshots.map((snapshot) => (
|
||||||
|
<tr key={snapshot.id}>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-gray-800">{snapshot.created_at}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{snapshot.scheduled ? '定时' : '手动'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-gray-600">{snapshot.created_by || '-'}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onRestore(snapshot)}
|
||||||
|
disabled={!!busy}
|
||||||
|
className="rounded border border-gray-300 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === snapshot.id ? '处理中...' : '恢复'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(snapshot)}
|
||||||
|
disabled={!!busy}
|
||||||
|
className="rounded border border-red-200 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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 }) {
|
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) {
|
if (mappings.length === 0) {
|
||||||
return <p className="text-sm text-gray-400">暂无端口映射</p>
|
return <p className="text-sm text-gray-400">暂无端口映射</p>
|
||||||
@@ -1389,6 +1757,19 @@ function formatExpiration(value?: string): string {
|
|||||||
return value.length >= 10 ? value.slice(0, 10) : value
|
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 {
|
function formatRate(bytesPerSecond: number): string {
|
||||||
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
|
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
|
||||||
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
||||||
|
|||||||
@@ -538,8 +538,15 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
|||||||
ssh_password: '',
|
ssh_password: '',
|
||||||
port_mappings: [],
|
port_mappings: [],
|
||||||
port_mapping_limit: 2,
|
port_mapping_limit: 2,
|
||||||
|
snapshot_limit: cfg.snapshot_limit || 3,
|
||||||
created_at: '',
|
created_at: '',
|
||||||
expires_at: cfg.expires_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,
|
isPlaceholder: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Snapshot[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-black">快照管理</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">全局快照列表,共 {snapshots.length} 个</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRefreshing(true); fetchData() }}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||||
|
{snapshots.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<Camera className="h-7 w-7 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">暂无快照</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[820px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">容器</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">LXC 名称</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">快照时间</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">类型</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">创建者</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">大小</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{snapshots.map((snapshot) => (
|
||||||
|
<tr key={snapshot.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/container/${snapshot.container_id}`)}
|
||||||
|
className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline"
|
||||||
|
>
|
||||||
|
<Server className="h-4 w-4 text-gray-400" />
|
||||||
|
{snapshot.container_name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{snapshot.lxc_name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">{snapshot.created_at}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`rounded px-2 py-1 text-xs ${snapshot.scheduled ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{snapshot.scheduled ? '定时' : '手动'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
@@ -71,8 +71,15 @@ export interface Container {
|
|||||||
ssh_password: string
|
ssh_password: string
|
||||||
port_mappings: PortMapping[]
|
port_mappings: PortMapping[]
|
||||||
port_mapping_limit: number
|
port_mapping_limit: number
|
||||||
|
snapshot_limit: number
|
||||||
created_at: string
|
created_at: string
|
||||||
expires_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 {
|
export interface Template {
|
||||||
@@ -100,6 +107,7 @@ export interface CreateContainerRequest {
|
|||||||
io_speed_mbps: number
|
io_speed_mbps: number
|
||||||
extra_ports: number[]
|
extra_ports: number[]
|
||||||
port_mapping_count: number
|
port_mapping_count: number
|
||||||
|
snapshot_limit: number
|
||||||
assign_ipv6: boolean
|
assign_ipv6: boolean
|
||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
@@ -354,6 +362,62 @@ export const getOversellStatus = () =>
|
|||||||
export const reclaimMemory = () =>
|
export const reclaimMemory = () =>
|
||||||
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim')
|
api.post<APIResponse<ReclaimResult>>('/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<APIResponse<Snapshot[]>>('/snapshots')
|
||||||
|
|
||||||
|
export const getContainerSnapshots = (id: ContainerIdentifier) =>
|
||||||
|
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
|
||||||
|
|
||||||
|
export const createContainerSnapshot = (id: ContainerIdentifier) =>
|
||||||
|
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
|
||||||
|
|
||||||
|
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||||
|
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
|
||||||
|
|
||||||
|
export const restoreContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||||
|
api.post<APIResponse>(`/containers/${id}/snapshots/${snapshotId}/restore`, {}, { timeout: 600000 })
|
||||||
|
|
||||||
|
export const updateSnapshotSchedule = (id: ContainerIdentifier, enabled: boolean, intervalHours: number, time: string) =>
|
||||||
|
api.post<APIResponse<{ container: Container; snapshot?: Snapshot }>>(
|
||||||
|
`/containers/${id}/snapshots/schedule`,
|
||||||
|
{ enabled, interval_hours: intervalHours, time },
|
||||||
|
{ timeout: 600000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
export const updateSnapshotQuota = (id: ContainerIdentifier, snapshotLimit: number) =>
|
||||||
|
api.put<APIResponse<{ container: Container; quota: number }>>(
|
||||||
|
`/containers/${id}/snapshots/quota`,
|
||||||
|
{ snapshot_limit: snapshotLimit }
|
||||||
|
)
|
||||||
|
|
||||||
// WebSSH URL generator
|
// WebSSH URL generator
|
||||||
export const getWebSSHUrl = (containerName: string) => {
|
export const getWebSSHUrl = (containerName: string) => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
|||||||
Reference in New Issue
Block a user