mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Backend: - GET /services/:id/metrics with real Docker container stats - DELETE /git/providers/:id and GET repo branches endpoint Frontend: - Service detail shows real telemetry or honest empty states - Live status distinguishes idle/polling/reconnecting - Git providers section in Settings; repo+branch pickers in Add Service - Real logo, compact Projects header, canvas fitView fix - index.css deduplicated (1725 -> 1384 lines) - Fixed smoke tests, lint errors, ToastProvider/context unification Repo hygiene: - Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README - Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore - Removed legacy root src/internal/cmd and stale configs/docs/artifacts - Moved guides and design references under docs/ Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
322 lines
8.0 KiB
Go
322 lines
8.0 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"containr/internal/docker"
|
|
|
|
"github.com/docker/docker/api/types/registry"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type upgradeStatusResponse struct {
|
|
ImageRef string `json:"image_ref"`
|
|
Registry string `json:"registry"`
|
|
DockerAvailable bool `json:"docker_available"`
|
|
Installed bool `json:"installed"`
|
|
Digest string `json:"digest,omitempty"`
|
|
Size int64 `json:"size,omitempty"`
|
|
AuthConfigured bool `json:"auth_configured"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type hostMonitoringResponse struct {
|
|
Hostname string `json:"hostname"`
|
|
OS string `json:"os"`
|
|
Architecture string `json:"architecture"`
|
|
CPU hostCPU `json:"cpu"`
|
|
Memory hostMemory `json:"memory"`
|
|
Storage hostStorage `json:"storage"`
|
|
Load SystemLoad `json:"load"`
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
DockerAvailable bool `json:"docker_available"`
|
|
Docker map[string]any `json:"docker,omitempty"`
|
|
CollectedAt string `json:"collected_at"`
|
|
}
|
|
|
|
type hostCPU struct {
|
|
Cores int `json:"cores"`
|
|
}
|
|
|
|
type hostMemory struct {
|
|
Total uint64 `json:"total"`
|
|
Used uint64 `json:"used"`
|
|
Available uint64 `json:"available"`
|
|
UsagePct float64 `json:"usage_percent"`
|
|
}
|
|
|
|
type hostStorage struct {
|
|
Path string `json:"path"`
|
|
Total uint64 `json:"total"`
|
|
Used uint64 `json:"used"`
|
|
Available uint64 `json:"available"`
|
|
UsagePct float64 `json:"usage_percent"`
|
|
}
|
|
|
|
func handleGetUpgradeStatus(c *gin.Context) {
|
|
imageRef := configuredUpgradeImageRef()
|
|
dockerClient, _ := c.Get("docker_client")
|
|
client, _ := dockerClient.(*docker.Client)
|
|
|
|
response := upgradeStatusResponse{
|
|
ImageRef: imageRef,
|
|
Registry: registryHost(imageRef),
|
|
DockerAvailable: client != nil,
|
|
AuthConfigured: upgradeRegistryAuthConfigured(),
|
|
Message: "Upgrade image auto-detected",
|
|
}
|
|
|
|
if imageRef == "" {
|
|
response.Message = "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF to enable image pulls"
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
|
|
if client == nil {
|
|
response.Message = "Docker client unavailable"
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
info, err := client.GetImageInfo(ctx, imageRef)
|
|
if err != nil {
|
|
response.Message = "Image not installed locally"
|
|
c.JSON(http.StatusOK, response)
|
|
return
|
|
}
|
|
|
|
response.Installed = true
|
|
response.Digest = info.Digest
|
|
response.Size = info.Size
|
|
response.Message = "Image installed locally"
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
func handlePullUpgradeImage(c *gin.Context) {
|
|
imageRef := configuredUpgradeImageRef()
|
|
if imageRef == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF before pulling"})
|
|
return
|
|
}
|
|
|
|
dockerClient, _ := c.Get("docker_client")
|
|
client, ok := dockerClient.(*docker.Client)
|
|
if !ok || client == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker client unavailable"})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
reader, err := client.PullImage(ctx, imageRef, upgradeRegistryAuth())
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
_, _ = io.Copy(io.Discard, reader)
|
|
|
|
info, infoErr := client.GetImageInfo(ctx, imageRef)
|
|
response := gin.H{
|
|
"image_ref": imageRef,
|
|
"registry": registryHost(imageRef),
|
|
"message": "Image pull completed",
|
|
}
|
|
if infoErr == nil {
|
|
response["digest"] = info.Digest
|
|
response["size"] = info.Size
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
func handleGetHostMonitoring(c *gin.Context) {
|
|
hostname, _ := os.Hostname()
|
|
memory := readHostMemory()
|
|
storage := readHostStorage("/")
|
|
load := readSystemLoad()
|
|
uptime := readSystemUptime()
|
|
|
|
response := hostMonitoringResponse{
|
|
Hostname: hostname,
|
|
OS: runtime.GOOS,
|
|
Architecture: runtime.GOARCH,
|
|
CPU: hostCPU{Cores: runtime.NumCPU()},
|
|
Memory: memory,
|
|
Storage: storage,
|
|
Load: load,
|
|
UptimeSeconds: uptime,
|
|
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
if dockerClient, ok := c.Get("docker_client"); ok {
|
|
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
|
defer cancel()
|
|
if info, err := client.GetSystemInfo(ctx); err == nil {
|
|
response.DockerAvailable = true
|
|
response.Docker = map[string]any{
|
|
"containers": info.Containers,
|
|
"images": info.Images,
|
|
"driver": info.Driver,
|
|
"server": info.ServerVersion,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
func configuredUpgradeImageRef() string {
|
|
for _, key := range []string{"CONTAINR_UPGRADE_IMAGE_REF", "CONTAINR_IMAGE_REF", "DOCKER_IMAGE_REF"} {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func registryHost(imageRef string) string {
|
|
parts := strings.Split(imageRef, "/")
|
|
if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
|
return parts[0]
|
|
}
|
|
if strings.HasPrefix(imageRef, "ghcr.io/") {
|
|
return "ghcr.io"
|
|
}
|
|
return "docker.io"
|
|
}
|
|
|
|
func upgradeRegistryAuthConfigured() bool {
|
|
auth := upgradeRegistryAuth()
|
|
return strings.TrimSpace(auth.Username) != "" || strings.TrimSpace(auth.Password) != "" || strings.TrimSpace(auth.Auth) != ""
|
|
}
|
|
|
|
func upgradeRegistryAuth() registry.AuthConfig {
|
|
username := firstConfigured("GITHUB_CONTAINER_REGISTRY_USER", "GHCR_USERNAME", "DOCKER_REGISTRY_USERNAME")
|
|
password := firstConfigured("GITHUB_CONTAINER_REGISTRY_TOKEN", "GHCR_TOKEN", "DOCKER_REGISTRY_PASSWORD")
|
|
return registry.AuthConfig{
|
|
Username: username,
|
|
Password: password,
|
|
}
|
|
}
|
|
|
|
func firstConfigured(keys ...string) string {
|
|
for _, key := range keys {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readHostMemory() hostMemory {
|
|
values := map[string]uint64{}
|
|
file, err := os.Open("/proc/meminfo")
|
|
if err != nil {
|
|
return hostMemory{}
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
fields := strings.Fields(scanner.Text())
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
key := strings.TrimSuffix(fields[0], ":")
|
|
value, parseErr := strconv.ParseUint(fields[1], 10, 64)
|
|
if parseErr != nil {
|
|
continue
|
|
}
|
|
values[key] = value * 1024
|
|
}
|
|
|
|
total := values["MemTotal"]
|
|
available := values["MemAvailable"]
|
|
used := uint64(0)
|
|
if total > available {
|
|
used = total - available
|
|
}
|
|
|
|
usage := 0.0
|
|
if total > 0 {
|
|
usage = float64(used) / float64(total) * 100
|
|
}
|
|
|
|
return hostMemory{
|
|
Total: total,
|
|
Used: used,
|
|
Available: available,
|
|
UsagePct: usage,
|
|
}
|
|
}
|
|
|
|
func readHostStorage(path string) hostStorage {
|
|
var stat syscall.Statfs_t
|
|
if err := syscall.Statfs(path, &stat); err != nil {
|
|
return hostStorage{Path: path}
|
|
}
|
|
|
|
total := stat.Blocks * uint64(stat.Bsize)
|
|
available := stat.Bavail * uint64(stat.Bsize)
|
|
used := total - available
|
|
usage := 0.0
|
|
if total > 0 {
|
|
usage = float64(used) / float64(total) * 100
|
|
}
|
|
|
|
return hostStorage{
|
|
Path: path,
|
|
Total: total,
|
|
Used: used,
|
|
Available: available,
|
|
UsagePct: usage,
|
|
}
|
|
}
|
|
|
|
func readSystemLoad() SystemLoad {
|
|
data, err := os.ReadFile("/proc/loadavg")
|
|
if err != nil {
|
|
return SystemLoad{}
|
|
}
|
|
fields := strings.Fields(string(data))
|
|
if len(fields) < 3 {
|
|
return SystemLoad{}
|
|
}
|
|
load1, _ := strconv.ParseFloat(fields[0], 64)
|
|
load5, _ := strconv.ParseFloat(fields[1], 64)
|
|
load15, _ := strconv.ParseFloat(fields[2], 64)
|
|
return SystemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
|
}
|
|
|
|
func readSystemUptime() int64 {
|
|
data, err := os.ReadFile("/proc/uptime")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
fields := strings.Fields(string(data))
|
|
if len(fields) == 0 {
|
|
return 0
|
|
}
|
|
uptime, err := strconv.ParseFloat(fields[0], 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return int64(uptime)
|
|
}
|