Files
Containr/app/backend/internal/api/metrics.go
Tomas DvorakandDevin 8b6214a0f5 feat: public platform, admin console, templates, landing docs
Projects become publicly browsable after admin approval; management and
secret-adjacent reads (logs, variables, exec, connection URLs) require
owner/member/admin auth, and platform controls sit behind RequireAdmin.

- Backend: OptionalAuth middleware, read/authed/admin route groups,
  projects.is_approved + service_templates.owner_id migration,
  visibility-aware sqlc queries, admin overview/users/approval endpoints,
  EnsureAdminAccount bootstrap (ADMIN_* env), 30 seeded templates with
  owner CRUD, managed-database connection URLs gated owner+admin, fix
  DB.Stats() infinite recursion.
- Frontend: expandable persisted sidebar + clickable user menu, public
  landing page with searchable docs browser (GitHub sync, bundled
  snapshot + localStorage fallback), DocsBrowser shared with /docs,
  template create/edit/JSON-upload for owned templates, database create
  dialog, admin console (stats, approvals, project edit/delete, user
  admin toggles), HA/Security/Usage polish with dead CSS tokens mapped
  to real theme vars, anonymous-safe gating on every mutation control.
- Tests: safe window.localStorage access fixes happy-dom crashes in
  PlatformShell and canvas storage.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 19:58:59 +02:00

201 lines
5.9 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/docker"
containertypes "github.com/docker/docker/api/types/container"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ServiceMetricsResponse is the aggregated live telemetry for a service,
// sourced from Docker container stats for containers named
// containr-<serviceID>-<deploymentID>-<index>.
type ServiceMetricsResponse struct {
ServiceID string `json:"service_id"`
Status string `json:"status"` // ok, no_containers, docker_unavailable
Instances []ServiceInstanceMetrics `json:"instances"`
CPUPercent float64 `json:"cpu_percent"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
NetworkRxBytes uint64 `json:"network_rx_bytes"`
NetworkTxBytes uint64 `json:"network_tx_bytes"`
CollectedAt time.Time `json:"collected_at"`
}
// ServiceInstanceMetrics holds live stats for one running container.
type ServiceInstanceMetrics struct {
ContainerID string `json:"container_id"`
Name string `json:"name"`
State string `json:"state"`
CPUPercent float64 `json:"cpu_percent"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
NetworkRxBytes uint64 `json:"network_rx_bytes"`
NetworkTxBytes uint64 `json:"network_tx_bytes"`
StartedAt time.Time `json:"started_at"`
}
// handleGetServiceMetrics returns real resource usage for the service's
// Docker containers. Empty result when the service has never deployed.
func handleGetServiceMetrics(c *gin.Context) {
dbValue, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
db := dbValue.(*database.DB)
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
var projectID uuid.UUID
var status string
err = db.QueryRow(
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&projectID, &status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
response := ServiceMetricsResponse{
ServiceID: serviceID.String(),
Status: "ok",
Instances: []ServiceInstanceMetrics{},
CollectedAt: time.Now(),
}
dockerValue, exists := c.Get("docker_client")
if !exists || dockerValue == nil {
response.Status = "docker_unavailable"
c.JSON(http.StatusOK, gin.H{"metrics": response})
return
}
dockerClient := dockerValue.(*docker.Client)
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
containers, err := dockerClient.ListContainers(ctx, false)
if err != nil {
response.Status = "docker_unavailable"
c.JSON(http.StatusOK, gin.H{"metrics": response})
return
}
namePrefix := fmt.Sprintf("containr-%s-", serviceID.String())
matched := 0
for _, ctr := range containers {
name := ""
for _, n := range ctr.Names {
name = strings.TrimPrefix(n, "/")
if strings.HasPrefix(name, namePrefix) {
break
}
name = ""
}
if name == "" {
continue
}
matched++
instance := ServiceInstanceMetrics{
ContainerID: ctr.ID,
Name: name,
State: ctr.State,
}
if info, err := dockerClient.GetContainer(ctx, ctr.ID); err == nil {
if info.State != nil && info.State.StartedAt != "" {
if parsed, perr := time.Parse(time.RFC3339Nano, info.State.StartedAt); perr == nil {
instance.StartedAt = parsed
}
}
}
if ctr.State == "running" {
if stats, err := readContainerStats(ctx, dockerClient, ctr.ID); err == nil {
instance.CPUPercent = stats.cpuPercent
instance.MemoryUsageBytes = stats.memoryUsage
instance.MemoryLimitBytes = stats.memoryLimit
instance.NetworkRxBytes = stats.networkRx
instance.NetworkTxBytes = stats.networkTx
}
}
response.Instances = append(response.Instances, instance)
response.CPUPercent += instance.CPUPercent
response.MemoryUsageBytes += instance.MemoryUsageBytes
response.MemoryLimitBytes += instance.MemoryLimitBytes
response.NetworkRxBytes += instance.NetworkRxBytes
response.NetworkTxBytes += instance.NetworkTxBytes
}
if matched == 0 {
response.Status = "no_containers"
}
c.JSON(http.StatusOK, gin.H{"metrics": response})
}
type containerStatsSnapshot struct {
cpuPercent float64
memoryUsage uint64
memoryLimit uint64
networkRx uint64
networkTx uint64
}
func readContainerStats(ctx context.Context, client *docker.Client, containerID string) (*containerStatsSnapshot, error) {
reader, err := client.GetContainerStats(ctx, containerID, false)
if err != nil {
return nil, err
}
defer reader.Body.Close()
var stats containertypes.StatsResponse
if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil {
return nil, err
}
snapshot := &containerStatsSnapshot{
memoryUsage: stats.MemoryStats.Usage,
memoryLimit: stats.MemoryStats.Limit,
}
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage)
systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage)
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
if onlineCPUs == 0 {
onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
}
if cpuDelta > 0 && systemDelta > 0 && onlineCPUs > 0 {
snapshot.cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100
}
for _, net := range stats.Networks {
snapshot.networkRx += net.RxBytes
snapshot.networkTx += net.TxBytes
}
return snapshot, nil
}