05440a5f92
Build / build (push) Successful in 10m50s
Background collector samples CPU/memory/network/block I/O for every
instance and site on a configurable interval (default 15s, range
5-300s), persists samples to SQLite with a configurable retention
window (default 2h, range 0-24h), and skips ticks gracefully when
the Docker daemon is unreachable. Settings are reloadable without
a restart — each tick re-reads them.
New API endpoints:
- GET /api/system/stats (host snapshot: info + df)
- GET /api/system/stats/history
- GET /api/system/stats/top?by=cpu|memory
- GET /api/projects/{id}/stages/{s}/instances/{iid}/stats/history
- GET /api/sites/{id}/stats[/history]
- GET /api/sites/{id}/logs (SSE + JSON, reuses instance log streamer)
Frontend:
- ECharts added with tree-shaken imports (~180KB gzip) for
future-proof time-series/gantt/graph visualizations
- CollapsibleSection wraps all dashboard sections (system health,
daemons, system resources, static sites, projects) with
localStorage-persisted open state
- SystemResourcesCard shows capacity tiles, workload utilization
chart with 30m/2h/6h/24h window picker, disk breakdown with
reclaimable callouts, and top 5 consumers
- ContainerStats and ContainerLogs take a source discriminated union
so sites reuse the same components as instances; sites detail page
embeds both for Deno backend debugging
- Settings › Maintenance exposes collection interval + retention
- Docker-unavailable state returns 503 and renders an amber banner
instead of a generic 500
Full i18n coverage (en + ru) for all new strings.
311 lines
8.4 KiB
Go
311 lines
8.4 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// listProjectImages handles GET /api/projects/{id}/images.
|
|
// Returns all local Docker images matching the project's image reference.
|
|
func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
project, err := s.store.GetProjectByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
slog.Error("failed to get project", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
if s.docker == nil || project.Image == "" {
|
|
respondJSON(w, http.StatusOK, []any{})
|
|
return
|
|
}
|
|
|
|
images, err := s.docker.ListImagesByRef(r.Context(), project.Image)
|
|
if err != nil {
|
|
slog.Warn("list project images", "project", project.Name, "error", err)
|
|
respondJSON(w, http.StatusOK, []any{})
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, images)
|
|
}
|
|
|
|
// streamContainerLogs handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs.
|
|
// Streams container logs via SSE. Supports query params:
|
|
// - tail: number of lines from end (default "200")
|
|
// - follow: "true" to stream new lines in real-time
|
|
func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
|
|
instanceID := chi.URLParam(r, "iid")
|
|
|
|
inst, err := s.store.GetInstanceByID(instanceID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "instance")
|
|
return
|
|
}
|
|
slog.Error("failed to get instance", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
if inst.ContainerID == "" {
|
|
respondError(w, http.StatusBadRequest, "instance has no container")
|
|
return
|
|
}
|
|
|
|
s.streamLogsForContainer(w, r, inst.ContainerID)
|
|
}
|
|
|
|
// streamLogsForContainer streams logs for an arbitrary container ID using the
|
|
// shared SSE/JSON dual-mode pattern. Owner-specific handlers (instance, site)
|
|
// should validate ownership and then delegate here.
|
|
func (s *Server) streamLogsForContainer(w http.ResponseWriter, r *http.Request, containerID string) {
|
|
if s.docker == nil {
|
|
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
|
return
|
|
}
|
|
|
|
tail := r.URL.Query().Get("tail")
|
|
if tail == "" {
|
|
tail = "200"
|
|
}
|
|
follow := r.URL.Query().Get("follow") == "true"
|
|
|
|
// Check if client accepts SSE.
|
|
accept := r.Header.Get("Accept")
|
|
isSSE := strings.Contains(accept, "text/event-stream")
|
|
|
|
logReader, err := s.docker.ContainerLogs(r.Context(), containerID, follow && isSSE, tail)
|
|
if err != nil {
|
|
slog.Error("failed to get container logs", "container", containerID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to get container logs")
|
|
return
|
|
}
|
|
defer logReader.Close()
|
|
|
|
if !isSSE {
|
|
// JSON mode: read all lines and return as array.
|
|
scanner := bufio.NewScanner(logReader)
|
|
var lines []string
|
|
for scanner.Scan() {
|
|
line := sanitizeDockerLogLine(scanner.Text())
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
if lines == nil {
|
|
lines = []string{}
|
|
}
|
|
respondJSON(w, http.StatusOK, lines)
|
|
return
|
|
}
|
|
|
|
// SSE mode: stream lines as they arrive.
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
respondError(w, http.StatusInternalServerError, "streaming not supported")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
scanner := bufio.NewScanner(logReader)
|
|
for scanner.Scan() {
|
|
line := sanitizeDockerLogLine(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
data, _ := json.Marshal(map[string]string{"line": line})
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
flusher.Flush()
|
|
|
|
// Check if client disconnected.
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// sanitizeDockerLogLine strips the Docker log stream header (8-byte prefix)
|
|
// that Docker adds to non-TTY container logs.
|
|
func sanitizeDockerLogLine(line string) string {
|
|
// Docker multiplexed stream: first 8 bytes are header (stream type + size).
|
|
// If the line starts with a non-printable byte followed by 0x00 0x00 0x00, strip 8 bytes.
|
|
if len(line) > 8 && (line[0] == 1 || line[0] == 2) && line[1] == 0 && line[2] == 0 && line[3] == 0 {
|
|
return line[8:]
|
|
}
|
|
return line
|
|
}
|
|
|
|
// unusedImageStats handles GET /api/docker/unused-images.
|
|
// Returns the total size of unused project images and whether the threshold is exceeded.
|
|
func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
|
|
if s.docker == nil {
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"total_size_mb": 0, "count": 0, "threshold_mb": 0, "exceeded": false,
|
|
})
|
|
return
|
|
}
|
|
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
slog.Error("unused images: get settings", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
projects, err := s.store.GetAllProjects()
|
|
if err != nil {
|
|
slog.Error("unused images: list projects", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Build set of active image refs.
|
|
activeImages := make(map[string]bool)
|
|
for _, p := range projects {
|
|
stages, _ := s.store.GetStagesByProjectID(p.ID)
|
|
for _, st := range stages {
|
|
instances, _ := s.store.GetInstancesByStageID(st.ID)
|
|
for _, inst := range instances {
|
|
if inst.ImageTag != "" {
|
|
activeImages[p.Image+":"+inst.ImageTag] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sum unused image sizes.
|
|
ctx := r.Context()
|
|
var totalSize int64
|
|
var count int
|
|
for _, p := range projects {
|
|
if p.Image == "" {
|
|
continue
|
|
}
|
|
images, err := s.docker.ListImagesByRef(ctx, p.Image)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, img := range images {
|
|
if !activeImages[img.Ref] {
|
|
totalSize += img.Size
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
|
|
totalMB := totalSize / (1024 * 1024)
|
|
exceeded := settings.ImagePruneThresholdMB > 0 && int(totalMB) >= settings.ImagePruneThresholdMB
|
|
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"total_size_mb": totalMB,
|
|
"count": count,
|
|
"threshold_mb": settings.ImagePruneThresholdMB,
|
|
"exceeded": exceeded,
|
|
})
|
|
}
|
|
|
|
// pruneImages handles POST /api/docker/prune-images.
|
|
// Only removes images that belong to Tinyforge projects (not all system images).
|
|
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
|
if s.docker == nil {
|
|
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
|
return
|
|
}
|
|
|
|
// Collect all image references from our projects.
|
|
projects, err := s.store.GetAllProjects()
|
|
if err != nil {
|
|
slog.Error("prune: failed to list projects", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Build a set of image refs used by active instances.
|
|
activeImages := make(map[string]bool)
|
|
for _, p := range projects {
|
|
stages, _ := s.store.GetStagesByProjectID(p.ID)
|
|
for _, st := range stages {
|
|
instances, _ := s.store.GetInstancesByStageID(st.ID)
|
|
for _, inst := range instances {
|
|
if inst.ImageTag != "" {
|
|
activeImages[p.Image+":"+inst.ImageTag] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect all unique image bases from projects (without tags).
|
|
projectImages := make(map[string]bool)
|
|
for _, p := range projects {
|
|
if p.Image != "" {
|
|
projectImages[p.Image] = true
|
|
}
|
|
}
|
|
|
|
if len(projectImages) == 0 {
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"images_removed": 0,
|
|
"space_reclaimed_mb": 0,
|
|
"message": "No project images to clean up",
|
|
})
|
|
return
|
|
}
|
|
|
|
// List all local Docker images and find ones matching our projects but not actively used.
|
|
ctx := r.Context()
|
|
removed := 0
|
|
var reclaimedBytes int64
|
|
|
|
for imageBase := range projectImages {
|
|
// List all tags for this image.
|
|
images, err := s.docker.ListImagesByRef(ctx, imageBase)
|
|
if err != nil {
|
|
slog.Warn("prune: list images", "image", imageBase, "error", err)
|
|
continue
|
|
}
|
|
|
|
for _, img := range images {
|
|
// Skip images that are actively used by running instances.
|
|
if activeImages[img.Ref] {
|
|
continue
|
|
}
|
|
|
|
// Remove unused image.
|
|
if err := s.docker.RemoveImage(ctx, img.ID); err != nil {
|
|
slog.Warn("prune: remove image", "image", img.Ref, "error", err)
|
|
continue
|
|
}
|
|
removed++
|
|
reclaimedBytes += img.Size
|
|
slog.Info("prune: removed image", "ref", img.Ref, "size_mb", img.Size/(1024*1024))
|
|
}
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"images_removed": removed,
|
|
"space_reclaimed_mb": reclaimedBytes / (1024 * 1024),
|
|
})
|
|
}
|