Files
kandev/apps/backend/internal/system/system.go
T
2026-06-15 13:33:45 +01:00

158 lines
5.5 KiB
Go

// Package system composes the System-pages backend: a top-level
// Provide that constructs the info, disk, database, backups, logs,
// updates, and jobs sub-services and registers the corresponding
// HTTP route group under /api/v1/system.
//
// The existing internal/health package continues to own
// GET /api/v1/system/health independently — this composer does not
// replace it. The wiring layer (cmd/kandev) registers health alongside
// this package.
package system
import (
"context"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/system/backups"
"github.com/kandev/kandev/internal/system/database"
"github.com/kandev/kandev/internal/system/disk"
"github.com/kandev/kandev/internal/system/info"
"github.com/kandev/kandev/internal/system/jobs"
"github.com/kandev/kandev/internal/system/logs"
"github.com/kandev/kandev/internal/system/metrics"
"github.com/kandev/kandev/internal/system/restart"
systemsettings "github.com/kandev/kandev/internal/system/settings"
"github.com/kandev/kandev/internal/system/updates"
"go.uber.org/zap"
)
// BuildInfo holds the ldflag-injected build metadata that cmd/kandev
// passes to Provide.
type BuildInfo struct {
Version string
Commit string
BuildTime string
}
// Wiring holds the runtime hooks the destructive endpoints need —
// currently only OrchestratorShutdown, which stops in-flight agent
// executions before factory reset wipes their backing data. The frontend
// dialog prompts the user to relaunch after a successful reset/restore;
// no in-process re-exec is performed.
type Wiring struct {
OrchestratorShutdown func()
}
// Service exposes the composed system sub-services. Each field is
// addressable so the cmd/kandev wiring can attach callbacks (Restart)
// after construction.
type Service struct {
Info *info.Service
Jobs *jobs.Tracker
Disk *disk.Service
Database *database.Service
Backups *backups.Service
Logs *logs.Service
Metrics *metrics.Service
Updates *updates.Service
Restart restart.Manager
}
// Provide constructs the composed Service. The HTTP routes are
// registered separately via RegisterRoutes and the updates poller is
// started via StartBackground, so callers can opt out (in tests, in
// CLI subcommands).
func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus.EventBus, build BuildInfo, wiring Wiring) *Service {
tracker := jobs.NewTracker(eventBus, log)
dataDir := cfg.ResolvedDataDir()
homeDir := cfg.ResolvedHomeDir()
resetDirs := database.ResetDirs{
Worktrees: filepath.Join(homeDir, "worktrees"),
Repos: filepath.Join(homeDir, "repos"),
Sessions: filepath.Join(homeDir, "sessions"),
Tasks: filepath.Join(homeDir, "tasks"),
QuickChat: filepath.Join(homeDir, "quick-chat"),
}
dbSvc := database.NewService(pool, dataDir, resetDirs, tracker, log)
dbSvc.OrchestratorShutdown = wiring.OrchestratorShutdown
backupsSvc := backups.NewService(dataDir, pool, tracker, log)
logDir := log.LogDirectory()
logFile := log.LogFilename()
settingsStore, err := systemsettings.NewStore(pool)
if err != nil {
log.Error("Failed to initialize system settings store", zap.Error(err))
}
var metricsSvc *metrics.Service
if settingsStore != nil {
metricsStore := metrics.NewStore(settingsStore)
metricsSvc = metrics.NewService(metricsStore, metrics.NewCollector())
}
return &Service{
Info: info.NewService(build.Version, build.Commit, build.BuildTime),
Jobs: tracker,
Disk: disk.NewService(homeDir, tracker, log),
Database: dbSvc,
Backups: backupsSvc,
Logs: logs.NewService(logDir, logFile, log),
Metrics: metricsSvc,
Updates: updates.NewService(pool, build.Version, nil, log, updates.WithHomeDir(homeDir), updates.WithJobs(tracker)),
Restart: restart.NewManagerFromEnv(),
}
}
// RegisterRoutes mounts every system endpoint under /api/v1/system.
// The health endpoint is mounted by internal/health.RegisterRoutes,
// not here, to keep the existing package's surface unchanged.
func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) {
g := router.Group("/api/v1/system")
g.GET("/info", info.Handler(s.Info))
g.GET("/disk-usage", disk.HandleGet(s.Disk))
g.POST("/disk-usage/refresh", disk.HandleRefresh(s.Disk))
g.POST("/disk-usage/open", disk.HandleOpenFolder(s.Disk))
g.GET("/database", database.HandleStats(s.Database))
g.POST("/database/vacuum", database.HandleVacuum(s.Database))
g.POST("/database/optimize", database.HandleOptimize(s.Database))
g.POST("/database/reset", database.HandleReset(s.Database))
backups.RegisterRoutes(g, s.Backups)
g.GET("/logs", logs.HandleList(s.Logs))
g.GET("/logs/tail", logs.HandleTail(s.Logs))
g.GET("/logs/:name/download", logs.HandleDownload(s.Logs))
if s.Metrics != nil {
metrics.RegisterRoutes(g, s.Metrics)
}
g.GET("/updates", updates.HandleGet(s.Updates))
g.POST("/updates/check", updates.HandleCheck(s.Updates))
g.POST("/updates/apply", updates.HandleApply(s.Updates))
g.GET("/restart-capability", restart.HandleCapability(s.Restart))
g.POST("/restart", restart.HandleRequest(s.Restart))
g.GET("/jobs/:id", jobs.HandleGet(s.Jobs))
log.Debug("Registered System routes (HTTP)")
}
// StartBackground kicks off the updates poller goroutine. The poller
// stops when ctx is cancelled.
func (s *Service) StartBackground(ctx context.Context) {
if s.Updates != nil {
s.Updates.StartPoller(ctx)
}
}