From 5c6d6eafc939d7398e5c4c273fe39b28f8ee749f Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:24:28 +0800 Subject: [PATCH] Support Debian 13. #29 and fix useage chart data display problem, storage on database instead of localstorage. --- .gitignore | 1 + backend/internal/api/container_metrics.go | 242 ++++++++++++++++++++++ backend/internal/api/handlers.go | 17 ++ backend/internal/api/host.go | 121 ++++++++++- backend/internal/api/subuser.go | 4 +- backend/internal/kvm/templates.go | 19 ++ backend/internal/lxc/templates.go | 5 + backend/internal/server/server.go | 4 + frontend/src/pages/ContainerDetail.tsx | 69 +++--- frontend/src/pages/Containers.tsx | 3 + frontend/src/pages/Dashboard.tsx | 64 +++--- frontend/src/services/api.ts | 31 +++ install.sh | 2 + 13 files changed, 507 insertions(+), 75 deletions(-) create mode 100644 backend/internal/api/container_metrics.go diff --git a/.gitignore b/.gitignore index 2493b83..202d44c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ deploy.ps1 backend/clicd api.md deploy-arm.ps1 +deploy-dhcp.ps1 diff --git a/backend/internal/api/container_metrics.go b/backend/internal/api/container_metrics.go new file mode 100644 index 0000000..f9b63ee --- /dev/null +++ b/backend/internal/api/container_metrics.go @@ -0,0 +1,242 @@ +package api + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "sync" + "time" + + "clicd/internal/config" +) + +type ContainerMetricPoint struct { + TS int64 `json:"ts"` + CPU float64 `json:"cpu"` + Memory float64 `json:"memory"` + Network float64 `json:"network"` + NetworkRx float64 `json:"network_rx"` + NetworkTx float64 `json:"network_tx"` + DiskIO float64 `json:"disk_io"` + DiskRead float64 `json:"disk_read"` + DiskWrite float64 `json:"disk_write"` +} + +var containerMetricSamplerOnce sync.Once +var containerMetricMu sync.RWMutex +var containerMetricHistory = map[string][]ContainerMetricPoint{} +var containerMetricInFlight sync.Map + +const ( + containerMetricSampleInterval = 30 * time.Second + containerMetricSampleTimeout = 20 * time.Second + containerMetricConcurrency = 4 +) + +func StartContainerMetricSampler() { + containerMetricSamplerOnce.Do(func() { + go func() { + sampleAllContainerMetrics() + ticker := time.NewTicker(containerMetricSampleInterval) + defer ticker.Stop() + for range ticker.C { + sampleAllContainerMetrics() + } + }() + }) +} + +func sampleAllContainerMetrics() { + containers, _ := listByRuntime() + sem := make(chan struct{}, containerMetricConcurrency) + var wg sync.WaitGroup + + for _, c := range containers { + c := c + if c.Status != "running" { + continue + } + sem <- struct{}{} + wg.Add(1) + go func() { + defer wg.Done() + defer func() { <-sem }() + sampleContainerMetricWithTimeout(c) + }() + } + wg.Wait() + pruneContainerMetricHistory() +} + +func sampleContainerMetricWithTimeout(c config.Container) { + key := containerMetricKey(c) + if key == "" { + return + } + if _, loaded := containerMetricInFlight.LoadOrStore(key, struct{}{}); loaded { + return + } + done := make(chan struct{}, 1) + go func() { + defer containerMetricInFlight.Delete(key) + if usage, err := usageByRuntime(c.ID); err == nil { + appendContainerMetricPoint(c, usage) + } + done <- struct{}{} + }() + + select { + case <-done: + case <-time.After(containerMetricSampleTimeout): + } +} + +func appendContainerMetricPoint(c config.Container, usage map[string]interface{}) { + key := containerMetricKey(c) + if key == "" { + return + } + memoryTotal := numberFromUsage(usage, "memory_total_bytes") + if memoryTotal <= 0 { + memoryTotal = float64(c.RAMMB) * 1024 * 1024 + } + memoryPct := 0.0 + if memoryTotal > 0 { + memoryPct = clampPercent(numberFromUsage(usage, "memory_usage_bytes") / memoryTotal * 100) + } + vcpu := c.VCPU + if vcpu <= 0 { + vcpu = 1 + } + cpuPct := clampPercent(numberFromUsage(usage, "cpu_usage_pct") / vcpu) + networkRx := positiveNumberFromUsage(usage, "network_rx_bps") + networkTx := positiveNumberFromUsage(usage, "network_tx_bps") + diskRead := positiveNumberFromUsage(usage, "disk_read_bps") + diskWrite := positiveNumberFromUsage(usage, "disk_write_bps") + point := ContainerMetricPoint{ + TS: time.Now().UnixMilli(), + CPU: cpuPct, + Memory: memoryPct, + NetworkRx: networkRx, + NetworkTx: networkTx, + Network: networkRx + networkTx, + DiskRead: diskRead, + DiskWrite: diskWrite, + DiskIO: diskRead + diskWrite, + } + cutoff := time.Now().Add(-hostMetricRetention).UnixMilli() + + containerMetricMu.Lock() + defer containerMetricMu.Unlock() + + history := containerMetricHistory[key] + keepFrom := 0 + for keepFrom < len(history) && history[keepFrom].TS < cutoff { + keepFrom++ + } + if keepFrom > 0 { + copy(history, history[keepFrom:]) + history = history[:len(history)-keepFrom] + } + containerMetricHistory[key] = append(history, point) +} + +func getContainerMetricHistory(c *config.Container) []ContainerMetricPoint { + if c == nil { + return nil + } + key := containerMetricKey(*c) + containerMetricMu.RLock() + defer containerMetricMu.RUnlock() + + history := containerMetricHistory[key] + result := make([]ContainerMetricPoint, len(history)) + copy(result, history) + return result +} + +func pruneContainerMetricHistory() { + cutoff := time.Now().Add(-hostMetricRetention).UnixMilli() + valid := map[string]bool{} + if config.AppConfig != nil { + for _, c := range config.AppConfig.Containers { + valid[containerMetricKey(c)] = true + } + } + + containerMetricMu.Lock() + defer containerMetricMu.Unlock() + + for key, history := range containerMetricHistory { + if !valid[key] { + delete(containerMetricHistory, key) + continue + } + keepFrom := 0 + for keepFrom < len(history) && history[keepFrom].TS < cutoff { + keepFrom++ + } + if keepFrom > 0 { + copy(history, history[keepFrom:]) + containerMetricHistory[key] = history[:len(history)-keepFrom] + } + } +} + +func containerMetricKey(c config.Container) string { + if c.UUID != "" { + return "uuid:" + c.UUID + } + if c.ID > 0 { + return fmt.Sprintf("id:%d", c.ID) + } + if c.Name != "" { + return "name:" + c.Name + } + return "" +} + +func numberFromUsage(usage map[string]interface{}, key string) float64 { + value, ok := usage[key] + if !ok || value == nil { + return 0 + } + switch v := value.(type) { + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + return v + case float32: + return float64(v) + case int: + return float64(v) + case int64: + return float64(v) + case int32: + return float64(v) + case uint: + return float64(v) + case uint64: + return float64(v) + case uint32: + return float64(v) + case json.Number: + n, _ := v.Float64() + return n + case string: + n, _ := strconv.ParseFloat(v, 64) + return n + default: + return 0 + } +} + +func positiveNumberFromUsage(usage map[string]interface{}, key string) float64 { + value := numberFromUsage(usage, key) + if value < 0 { + return 0 + } + return value +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 5f08f87..69551d5 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -132,6 +132,11 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) { return } getUsage(w, r, id) + case action == "history" && r.Method == http.MethodGet: + if !requireScope(w, r, "container:read") { + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getContainerMetricHistory(c)}) case action == "traffic" && r.Method == http.MethodGet: if !requireScope(w, r, "container:read") { return @@ -661,6 +666,18 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info}) } +// HandleHostHistory returns host resource samples collected by the server. +func HandleHostHistory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + return + } + if !requireScope(w, r, "host:read") { + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getHostMetricHistory()}) +} + func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) { c := config.FindContainer(id) if c != nil && lxc.IsExpired(*c) { diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go index c05a1b4..7569345 100644 --- a/backend/internal/api/host.go +++ b/backend/internal/api/host.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net" "net/http" "os" @@ -217,14 +218,35 @@ type DiskIOInfo struct { WriteBps float64 `json:"write_bps"` } +type HostMetricPoint struct { + TS int64 `json:"ts"` + CPU float64 `json:"cpu"` + Memory float64 `json:"memory"` + Network float64 `json:"network"` + NetworkRx float64 `json:"network_rx"` + NetworkTx float64 `json:"network_tx"` + DiskIO float64 `json:"disk_io"` + DiskRead float64 `json:"disk_read"` + DiskWrite float64 `json:"disk_write"` + DiskUsagePct float64 `json:"disk_usage_pct"` +} + var hostCPUMu sync.Mutex var lastHostCPU cpuTimes var hostIOMu sync.Mutex var lastHostIO hostIOSample +var hostMetricSamplerOnce sync.Once +var hostMetricMu sync.RWMutex +var hostMetricHistory []HostMetricPoint var egressIPv4Mu sync.Mutex var cachedEgressIPv4 lxc.PublicIPInfo var cachedEgressIPv4At time.Time +const ( + hostMetricSampleInterval = 30 * time.Second + hostMetricRetention = 7 * 24 * time.Hour +) + type cpuTimes struct { Total uint64 Idle uint64 @@ -239,6 +261,10 @@ type hostIOSample struct { } func getHostInfo() HostInfo { + return getHostInfoWithNetworkDetails(true) +} + +func getHostInfoWithNetworkDetails(includeDetails bool) HostInfo { info := HostInfo{ CPU: CpuInfo{Cores: runtime.NumCPU()}, } @@ -246,7 +272,7 @@ func getHostInfo() HostInfo { info.RAM = getMemoryInfo() info.Disk = getDiskInfo() info.CPU.Usage = getCPUUsage() - info.Network, info.DiskIO = getHostRates() + info.Network, info.DiskIO = getHostRates(includeDetails) info.Load = getLoadInfo() info.Runtime = detectRuntimeProbeQuick() return info @@ -274,6 +300,79 @@ func detectRuntimeProbeQuick() HostRuntimeProbe { return probe } +func StartHostMetricSampler() { + hostMetricSamplerOnce.Do(func() { + appendHostMetricPoint(getHostInfoWithNetworkDetails(false)) + go func() { + ticker := time.NewTicker(hostMetricSampleInterval) + defer ticker.Stop() + for range ticker.C { + appendHostMetricPoint(getHostInfoWithNetworkDetails(false)) + } + }() + }) +} + +func appendHostMetricPoint(info HostInfo) { + memoryPct := 0.0 + if info.RAM.TotalMB > 0 { + memoryPct = clampPercent(float64(info.RAM.UsedMB) / float64(info.RAM.TotalMB) * 100) + } + diskUsagePct := 0.0 + if info.Disk.TotalGB > 0 { + diskUsagePct = clampPercent(info.Disk.UsedGB / info.Disk.TotalGB * 100) + } + point := HostMetricPoint{ + TS: time.Now().UnixMilli(), + CPU: clampPercent(info.CPU.Usage), + Memory: memoryPct, + NetworkRx: info.Network.RXBps, + NetworkTx: info.Network.TXBps, + Network: info.Network.RXBps + info.Network.TXBps, + DiskRead: info.DiskIO.ReadBps, + DiskWrite: info.DiskIO.WriteBps, + DiskIO: info.DiskIO.ReadBps + info.DiskIO.WriteBps, + DiskUsagePct: diskUsagePct, + } + cutoff := time.Now().Add(-hostMetricRetention).UnixMilli() + + hostMetricMu.Lock() + defer hostMetricMu.Unlock() + + keepFrom := 0 + for keepFrom < len(hostMetricHistory) && hostMetricHistory[keepFrom].TS < cutoff { + keepFrom++ + } + if keepFrom > 0 { + copy(hostMetricHistory, hostMetricHistory[keepFrom:]) + hostMetricHistory = hostMetricHistory[:len(hostMetricHistory)-keepFrom] + } + hostMetricHistory = append(hostMetricHistory, point) +} + +func getHostMetricHistory() []HostMetricPoint { + hostMetricMu.RLock() + defer hostMetricMu.RUnlock() + + result := make([]HostMetricPoint, len(hostMetricHistory)) + copy(result, hostMetricHistory) + return result +} + +func clampPercent(value float64) float64 { + if value < 0 || !isFiniteFloat(value) { + return 0 + } + if value > 100 { + return 100 + } + return value +} + +func isFiniteFloat(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} + func getMemoryInfo() MemoryInfo { f, err := os.Open("/proc/meminfo") if err != nil { @@ -419,20 +518,22 @@ func parseSizeGBf(s string) (float64, error) { return val, err } -func getHostRates() (NetworkInfo, DiskIOInfo) { +func getHostRates(includeDetails bool) (NetworkInfo, DiskIOInfo) { rx, tx := readHostNetworkBytes() readBytes, writeBytes := readHostDiskBytes() now := unixNano() network := NetworkInfo{RXBytes: rx, TXBytes: tx} - publicIPv4 := detectDisplayPublicIPv4() - network.PublicIPv4 = publicIPv4.Address - network.PublicIPv4Interface = publicIPv4.Interface - network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0) - network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes() - if len(network.IPv6Prefixes) > 0 { - network.PublicIPv6 = network.IPv6Prefixes[0].Address - network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface + if includeDetails { + publicIPv4 := detectDisplayPublicIPv4() + network.PublicIPv4 = publicIPv4.Address + network.PublicIPv4Interface = publicIPv4.Interface + network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0) + network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes() + if len(network.IPv6Prefixes) > 0 { + network.PublicIPv6 = network.IPv6Prefixes[0].Address + network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface + } } diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes} diff --git a/backend/internal/api/subuser.go b/backend/internal/api/subuser.go index d7eaf36..67b86a7 100644 --- a/backend/internal/api/subuser.go +++ b/backend/internal/api/subuser.go @@ -646,7 +646,7 @@ func isSubUserBlockedAction(action string, method string) bool { return method != http.MethodGet } switch action { - case "usage", "traffic": + case "usage", "traffic", "history": return method != http.MethodGet default: return true @@ -665,7 +665,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool { return method == http.MethodGet } switch { - case action == "usage" || action == "traffic" || action == "random-port": + case action == "usage" || action == "traffic" || action == "history" || action == "random-port": return method == http.MethodGet case action == "snapshots": return method == http.MethodGet || method == http.MethodPost diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go index 0f85bf9..4558fa8 100644 --- a/backend/internal/kvm/templates.go +++ b/backend/internal/kvm/templates.go @@ -46,12 +46,25 @@ func amd64Images() []Image { Description: "Ubuntu 22.04 LTS cloud image for KVM", URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img", }, + { + ID: "kvm-debian-trixie", Name: "Debian 13 KVM", + Distro: "debian", Release: "trixie", Arch: "amd64", + Description: "Debian 13 generic cloud image for KVM", + URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", + }, { ID: "kvm-debian-bookworm", Name: "Debian 12 KVM", Distro: "debian", Release: "bookworm", Arch: "amd64", Description: "Debian 12 generic cloud image for KVM", URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2", }, + { + ID: "kvm-debian-trixie-xfce", Name: "Debian 13 XFCE KVM", + Distro: "debian", Release: "trixie", Arch: "amd64", + Description: "Debian 13 generic cloud image with XFCE desktop provisioned via cloud-init", + URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", + Desktop: "xfce", + }, { ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM", Distro: "debian", Release: "bookworm", Arch: "amd64", @@ -118,6 +131,12 @@ func arm64Images() []Image { Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM", URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img", }, + { + ID: "kvm-debian-trixie", Name: "Debian 13 KVM", + Distro: "debian", Release: "trixie", Arch: "arm64", + Description: "Debian 13 generic cloud image for ARM64 KVM", + URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2", + }, { ID: "kvm-debian-bookworm", Name: "Debian 12 KVM", Distro: "debian", Release: "bookworm", Arch: "arm64", diff --git a/backend/internal/lxc/templates.go b/backend/internal/lxc/templates.go index ae0cb03..d43e6c1 100644 --- a/backend/internal/lxc/templates.go +++ b/backend/internal/lxc/templates.go @@ -27,6 +27,11 @@ func GetTemplates() []Template { Distro: "ubuntu", Release: "jammy", Arch: arch, Description: "Ubuntu 22.04 LTS", }, + { + ID: "debian-trixie", Name: "Debian 13", + Distro: "debian", Release: "trixie", Arch: arch, + Description: "Debian 13 (Trixie)", + }, { ID: "debian-bookworm", Name: "Debian 12", Distro: "debian", Release: "bookworm", Arch: arch, diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index f41861b..017c1b2 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -61,6 +61,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/host-history", corsMiddleware(api.AdminMiddleware(api.HandleHostHistory))) mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport))) mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan))) @@ -104,6 +105,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle))) mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages)))) mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo))) + mux.HandleFunc("/api/v1/host-history", corsMiddleware(api.AuthMiddleware(api.HandleHostHistory))) mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport))) mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots)))) mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan))) @@ -174,6 +176,8 @@ func setupRoutes(mux *http.ServeMux) { func Run() error { // Use embedded frontend files webFS = GetEmbeddedFS() + api.StartHostMetricSampler() + api.StartContainerMetricSampler() mux := http.NewServeMux() setupRoutes(mux) diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index 397059a..3724ba8 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -32,6 +32,7 @@ import { assignIPv6, APIResponse, Container, + ContainerMetricPoint as ContainerMetricSample, ContainerUsage, createSubUser, createContainerSnapshot, @@ -39,6 +40,7 @@ import { deleteContainerSnapshot, deletePortMapping, getContainer, + getContainerHistory, getContainerSnapshots, getContainerUsage, getHostInfo, @@ -209,39 +211,19 @@ export default function ContainerDetail() { } }, [containerIdentifier, container?.snapshot_limit]) - const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => { - if (!containerIdentifier || !currentContainer) return - - const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0 - ? nextUsage.memory_total_bytes - : currentContainer.ram_mb * 1024 * 1024 - const memoryPct = memoryTotalBytes > 0 - ? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100 - : 0 - const networkRx = nextUsage.network_rx_bps || 0 - const networkTx = nextUsage.network_tx_bps || 0 - const diskRead = nextUsage.disk_read_bps || 0 - const diskWrite = nextUsage.disk_write_bps || 0 - - const point: MetricPoint = { - ts: Date.now(), - cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)), - memory: clamp(memoryPct), - network: networkRx + networkTx, - networkRx, - networkTx, - diskIO: diskRead + diskWrite, - diskRead, - diskWrite, + const fetchMetricHistory = useCallback(async () => { + if (!containerIdentifier) return + try { + const res = await getContainerHistory(containerIdentifier) + const points = (res.data.data || []).map(normalizeContainerMetricSample) + if (points.length > 0) { + setHistory(points) + localStorage.setItem(historyKey(container?.uuid || containerIdentifier), JSON.stringify(points)) + } + } catch (err) { + console.error('Failed to fetch metric history:', err) } - - setHistory((prev) => { - const cutoff = Date.now() - statsRanges['1w'] - const next = [...prev.filter((item) => item.ts >= cutoff), point] - localStorage.setItem(historyKey(currentContainer.uuid || containerIdentifier), JSON.stringify(next)) - return next - }) - }, [containerIdentifier]) + }, [containerIdentifier, container?.uuid]) const fetchUsage = useCallback(async () => { if (!containerIdentifier) return @@ -249,12 +231,11 @@ export default function ContainerDetail() { const res = await getContainerUsage(containerIdentifier) if (res.data.data) { setUsage(res.data.data) - appendUsagePoint(res.data.data, container) } } catch (err) { console.error('Failed to fetch usage:', err) } - }, [containerIdentifier, container, appendUsagePoint]) + }, [containerIdentifier]) useEffect(() => { if (!containerIdentifier) return @@ -296,6 +277,12 @@ export default function ContainerDetail() { return () => window.clearInterval(timer) }, [fetchUsage]) + useEffect(() => { + fetchMetricHistory() + const timer = window.setInterval(fetchMetricHistory, 30000) + return () => window.clearInterval(timer) + }, [fetchMetricHistory]) + useEffect(() => { if (showSnapshots) fetchSnapshots() }, [showSnapshots, fetchSnapshots]) @@ -2492,6 +2479,20 @@ function readHistory(containerName: string): MetricPoint[] { } } +function normalizeContainerMetricSample(point: ContainerMetricSample): MetricPoint { + return { + ts: point.ts, + cpu: clamp(point.cpu), + memory: clamp(point.memory), + network: point.network || 0, + networkRx: point.network_rx || 0, + networkTx: point.network_tx || 0, + diskIO: point.disk_io || 0, + diskRead: point.disk_read || 0, + diskWrite: point.disk_write || 0, + } +} + function historyKey(containerName: string) { return `clicd_container_metric_history:${containerName}` } diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 8dc21fc..cf8b050 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -967,6 +967,7 @@ function getTemplateName(id: string) { const map: Record = { 'ubuntu-noble': 'Ubuntu 24.04', 'ubuntu-jammy': 'Ubuntu 22.04', + 'debian-trixie': 'Debian 13', 'debian-bookworm': 'Debian 12', 'debian-bullseye': 'Debian 11', 'alpine-3.21': 'Alpine 3.21', @@ -976,6 +977,8 @@ function getTemplateName(id: string) { 'rockylinux-10': 'Rocky 10', 'kvm-ubuntu-noble': 'Ubuntu 24.04', 'kvm-ubuntu-jammy': 'Ubuntu 22.04', + 'kvm-debian-trixie': 'Debian 13', + 'kvm-debian-trixie-xfce': 'Debian 13 XFCE', 'kvm-debian-bookworm': 'Debian 12', 'kvm-debian-bullseye': 'Debian 11', 'kvm-rockylinux-9': 'Rocky 9', diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c2a29af..2287355 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -7,7 +7,7 @@ import ResourceStatsPanel, { StatsRangeKey, statsRanges, } from '../components/ResourceStatsPanel' -import { DashboardStats, getDashboard, getHostInfo, HostInfo } from '../services/api' +import { DashboardStats, getDashboard, getHostHistory, getHostInfo, HostInfo, HostMetricPoint as HostMetricSample } from '../services/api' type HostMetricPoint = { ts: number @@ -30,6 +30,19 @@ export default function Dashboard() { const [range, setRange] = useState('30m') const [loading, setLoading] = useState(true) + const fetchHistory = useCallback(async () => { + try { + const res = await getHostHistory() + const points = (res.data.data || []).map(normalizeHostMetricSample) + if (points.length > 0) { + setHistory(points) + localStorage.setItem(hostHistoryKey, JSON.stringify(points)) + } + } catch (err) { + console.error(err) + } + }, []) + const fetchData = useCallback(async () => { try { const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()]) @@ -37,7 +50,6 @@ export default function Dashboard() { if (hostRes.data.data) { const nextHost = hostRes.data.data setHost(nextHost) - appendHostPoint(nextHost, setHistory) } } catch (err) { console.error(err) @@ -47,10 +59,15 @@ export default function Dashboard() { }, []) useEffect(() => { + fetchHistory() fetchData() const interval = window.setInterval(fetchData, 5000) - return () => window.clearInterval(interval) - }, [fetchData]) + const historyInterval = window.setInterval(fetchHistory, 30000) + return () => { + window.clearInterval(interval) + window.clearInterval(historyInterval) + } + }, [fetchData, fetchHistory]) if (loading) { return ( @@ -172,31 +189,6 @@ function SummaryCard({ ) } -function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) { - const networkRx = host.network.rx_bps || 0 - const networkTx = host.network.tx_bps || 0 - const diskRead = host.disk_io.read_bps || 0 - const diskWrite = host.disk_io.write_bps || 0 - const point: HostMetricPoint = { - ts: Date.now(), - cpu: clamp(host.cpu.usage_pct), - memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0, - network: networkRx + networkTx, - networkRx, - networkTx, - diskIO: diskRead + diskWrite, - diskRead, - diskWrite, - } - - setHistory((prev) => { - const cutoff = Date.now() - statsRanges['1w'] - const next = [...prev.filter((item) => item.ts >= cutoff), point] - localStorage.setItem(hostHistoryKey, JSON.stringify(next)) - return next - }) -} - function readHostHistory(): HostMetricPoint[] { try { const raw = localStorage.getItem(hostHistoryKey) @@ -221,6 +213,20 @@ function toChartPoints(history: HostMetricPoint[], key: keyof Omit { success: boolean message?: string @@ -477,6 +502,9 @@ export const resetSSHPassword = (id: ContainerIdentifier, password?: string) => export const getContainerUsage = (id: ContainerIdentifier) => api.get>(`/containers/${id}/usage`) +export const getContainerHistory = (id: ContainerIdentifier) => + api.get>(`/containers/${id}/history`) + export interface TrafficInfo { total_used_bytes: number rx_used_bytes: number @@ -669,6 +697,9 @@ export const getDashboard = () => export const getHostInfo = () => api.get>('/host-info') +export const getHostHistory = () => + api.get>('/host-history') + export const getHostReport = () => api.get>('/host-report') diff --git a/install.sh b/install.sh index 36bffbd..be62333 100644 --- a/install.sh +++ b/install.sh @@ -537,6 +537,7 @@ remove_clicd_lxc_image_cache() { for image in \ "ubuntu noble amd64" \ "ubuntu jammy amd64" \ + "debian trixie amd64" \ "debian bookworm amd64" \ "debian bullseye amd64" \ "alpine 3.21 amd64" \ @@ -546,6 +547,7 @@ remove_clicd_lxc_image_cache() { "rockylinux 10 amd64" \ "ubuntu noble arm64" \ "ubuntu jammy arm64" \ + "debian trixie arm64" \ "debian bookworm arm64" \ "debian bullseye arm64" \ "alpine 3.21 arm64" \