mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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>
201 lines
5.9 KiB
Go
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
|
|
}
|