e217c3a445
After successful config apply, immediately push infra backup to Hub so the config sync status updates right away. Also fix startup event message that showed "vv0.21.2" instead of "v0.21.3". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1020 lines
34 KiB
Go
1020 lines
34 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
)
|
|
|
|
// Router handles all /api/* requests.
|
|
type Router struct {
|
|
cfg *config.Config
|
|
configPath string
|
|
sett *settings.Settings
|
|
stackMgr *stacks.Manager
|
|
syncer *catalogsync.Syncer
|
|
cpuCollector *system.CPUCollector
|
|
backupMgr *backup.Manager
|
|
crossDriveRunner *backup.CrossDriveRunner
|
|
metricsStore *metrics.MetricsStore
|
|
updater *selfupdate.Updater
|
|
notifier *notify.Notifier
|
|
logger *log.Logger
|
|
|
|
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
|
|
OnConfigApplied func()
|
|
}
|
|
|
|
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
|
|
return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
|
|
}
|
|
|
|
type apiResponse struct {
|
|
OK bool `json:"ok"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// ServeHTTP routes /api/* requests.
|
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
path := strings.TrimPrefix(req.URL.Path, "/api")
|
|
path = strings.TrimSuffix(path, "/")
|
|
|
|
switch {
|
|
// GET /api/stacks
|
|
case path == "/stacks" && req.Method == http.MethodGet:
|
|
r.listStacks(w, req)
|
|
|
|
// POST /api/stacks/rescan — re-scan stacks directory for new/removed stacks
|
|
case path == "/stacks/rescan" && req.Method == http.MethodPost:
|
|
r.rescanStacks(w, req)
|
|
|
|
// GET /api/stacks/{name}
|
|
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodGet && !hasSubpath(path, "/stacks/"):
|
|
r.getStack(w, req, trimSegment(path, "/stacks/"))
|
|
|
|
// GET /api/selfupdate/status — must be before hasSuffix-based stack cases
|
|
case path == "/selfupdate/status" && req.Method == http.MethodGet:
|
|
r.selfupdateStatus(w, req)
|
|
|
|
// POST /api/selfupdate/check — must be before hasSuffix-based stack cases
|
|
case path == "/selfupdate/check" && req.Method == http.MethodPost:
|
|
r.selfupdateCheck(w, req)
|
|
|
|
// POST /api/selfupdate/update — must be before hasSuffix("/update") stack case
|
|
case path == "/selfupdate/update" && req.Method == http.MethodPost:
|
|
r.selfupdateTrigger(w, req)
|
|
|
|
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
|
|
case path == "/config/apply" && req.Method == http.MethodPost:
|
|
r.configApply(w, req)
|
|
|
|
// GET /api/config/hash — return current config file hash
|
|
case path == "/config/hash" && req.Method == http.MethodGet:
|
|
r.configHash(w, req)
|
|
|
|
// GET /api/config — return raw controller.yaml content
|
|
case path == "/config" && req.Method == http.MethodGet:
|
|
r.configContent(w, req)
|
|
|
|
// GET /api/stacks/{name}/deploy-fields
|
|
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
|
|
r.getDeployFields(w, req, extractName(path, "/deploy-fields"))
|
|
|
|
// POST /api/stacks/{name}/deploy
|
|
case hasSuffix(path, "/deploy") && req.Method == http.MethodPost:
|
|
r.deployStack(w, req, extractName(path, "/deploy"))
|
|
|
|
// POST /api/stacks/{name}/start
|
|
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
|
|
r.actionStack(w, "start", extractName(path, "/start"))
|
|
|
|
// POST /api/stacks/{name}/stop
|
|
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
|
|
r.actionStack(w, "stop", extractName(path, "/stop"))
|
|
|
|
// POST /api/stacks/{name}/restart
|
|
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
|
|
r.actionStack(w, "restart", extractName(path, "/restart"))
|
|
|
|
// POST /api/stacks/{name}/update
|
|
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
|
|
r.actionStack(w, "update", extractName(path, "/update"))
|
|
|
|
// POST /api/stacks/{name}/optional-config
|
|
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
|
|
r.updateOptionalConfig(w, req, extractName(path, "/optional-config"))
|
|
|
|
// GET /api/stacks/{name}/logs
|
|
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
|
|
r.getStackLogs(w, req, extractName(path, "/logs"))
|
|
|
|
// GET /api/stacks/{name}/hdd-data
|
|
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
|
|
r.getStackHDDData(w, req, extractName(path, "/hdd-data"))
|
|
|
|
// GET /api/stacks/{name}/backup-data
|
|
case hasSuffix(path, "/backup-data") && req.Method == http.MethodGet:
|
|
r.getStackBackupData(w, req, extractName(path, "/backup-data"))
|
|
|
|
// POST /api/stacks/{name}/remove — remove a deployed (non-orphaned) stack
|
|
case hasSuffix(path, "/remove") && req.Method == http.MethodPost:
|
|
r.removeStack(w, req, extractName(path, "/remove"))
|
|
|
|
// DELETE /api/stacks/{name}
|
|
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
|
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
|
|
|
|
// POST /api/stacks/{name}/cross-backup — save cross-drive config
|
|
case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost && !hasSuffix(path, "/cross-backup/run") && !hasSuffix(path, "/cross-backup/status"):
|
|
r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup"))
|
|
|
|
// POST /api/stacks/{name}/cross-backup/run — trigger manual run
|
|
case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost:
|
|
r.triggerCrossBackup(w, req, extractName(path, "/cross-backup/run"))
|
|
|
|
// GET /api/stacks/{name}/cross-backup/status — poll status
|
|
case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet:
|
|
r.getCrossBackupStatus(w, req, extractName(path, "/cross-backup/status"))
|
|
|
|
// POST /api/backup/cross-drive/run-all — trigger all scheduled cross-drive backups
|
|
case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost:
|
|
r.triggerAllCrossBackups(w, req)
|
|
|
|
// POST /api/sync — trigger immediate catalog sync
|
|
case path == "/sync" && req.Method == http.MethodPost:
|
|
r.triggerSync(w, req)
|
|
|
|
// GET /api/system/info
|
|
case path == "/system/info" && req.Method == http.MethodGet:
|
|
r.systemInfo(w, req)
|
|
|
|
// GET /api/backup/status
|
|
case path == "/backup/status" && req.Method == http.MethodGet:
|
|
r.backupStatus(w, req)
|
|
|
|
// POST /api/backup/run
|
|
case path == "/backup/run" && req.Method == http.MethodPost:
|
|
r.triggerBackup(w, req)
|
|
|
|
// GET /api/backup/snapshots
|
|
case path == "/backup/snapshots" && req.Method == http.MethodGet:
|
|
r.backupSnapshots(w, req)
|
|
|
|
// GET /api/metrics/system
|
|
case path == "/metrics/system" && req.Method == http.MethodGet:
|
|
r.metricsSystem(w, req)
|
|
|
|
// GET /api/metrics/containers/summary
|
|
case path == "/metrics/containers/summary" && req.Method == http.MethodGet:
|
|
r.metricsContainerSummary(w, req)
|
|
|
|
// GET /api/metrics/containers/{name}
|
|
case strings.HasPrefix(path, "/metrics/containers/") && req.Method == http.MethodGet:
|
|
name := strings.TrimPrefix(path, "/metrics/containers/")
|
|
r.metricsContainer(w, req, name)
|
|
|
|
// GET /api/metrics/sysinfo
|
|
case path == "/metrics/sysinfo" && req.Method == http.MethodGet:
|
|
r.metricsSysInfo(w, req)
|
|
|
|
default:
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
|
|
}
|
|
}
|
|
|
|
// HealthHandler responds to /api/health (no auth required).
|
|
func (r *Router) HealthHandler(w http.ResponseWriter, req *http.Request) {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "felhom-controller is healthy"})
|
|
}
|
|
|
|
// --- Stack handlers ---
|
|
|
|
func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
|
|
}
|
|
|
|
func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
|
|
r.logger.Printf("[API] Manual stack rescan requested")
|
|
if err := r.stackMgr.ScanStacks(); err != nil {
|
|
r.logger.Printf("[API] Stack rescan failed: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
stackCount := len(r.stackMgr.GetStacks())
|
|
r.logger.Printf("[API] Stack rescan completed: %d stacks found", stackCount)
|
|
writeJSON(w, http.StatusOK, apiResponse{
|
|
OK: true,
|
|
Message: fmt.Sprintf("Rescan completed: %d stacks found", stackCount),
|
|
})
|
|
}
|
|
|
|
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
|
|
stack, ok := r.stackMgr.GetStack(name)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
|
|
}
|
|
|
|
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
|
|
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"metadata": meta,
|
|
"app_config": appCfg,
|
|
"domain": r.cfg.Customer.Domain,
|
|
"logo_url": r.cfg.AppLogoURL(meta.Slug),
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[API] Deploy requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
deployReq := stacks.DeployRequest{
|
|
StackName: name,
|
|
Values: body.Values,
|
|
}
|
|
|
|
warning, err := r.stackMgr.DeployStack(deployReq)
|
|
if err != nil {
|
|
r.logger.Printf("[API] Deploy failed for %s: %v", name, err)
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "already deployed") {
|
|
status = http.StatusConflict
|
|
}
|
|
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
|
|
status = http.StatusBadRequest
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
resp := apiResponse{OK: true, Message: "Stack " + name + " deployed"}
|
|
if warning != "" {
|
|
resp.Data = map[string]string{"warning": warning}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
|
|
// Push app deployed event to Hub
|
|
if r.notifier != nil {
|
|
displayName := name
|
|
if s, ok := r.stackMgr.GetStack(name); ok && s.Meta.DisplayName != "" {
|
|
displayName = s.Meta.DisplayName
|
|
}
|
|
r.notifier.NotifyAppDeployed(name, displayName)
|
|
}
|
|
}
|
|
|
|
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
|
r.logger.Printf("[API] %s requested for stack: %s", action, name)
|
|
|
|
// Protected stacks only allow restart — block all other actions
|
|
if r.cfg.IsProtectedStack(name) && action != "restart" {
|
|
writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: fmt.Sprintf("cannot %s protected stack %s", action, name)})
|
|
return
|
|
}
|
|
|
|
var err error
|
|
switch action {
|
|
case "start":
|
|
err = r.stackMgr.StartStack(name)
|
|
case "stop":
|
|
err = r.stackMgr.StopStack(name)
|
|
case "restart":
|
|
err = r.stackMgr.RestartStack(name)
|
|
case "update":
|
|
err = r.stackMgr.UpdateStack(name)
|
|
}
|
|
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "protected") {
|
|
status = http.StatusForbidden
|
|
}
|
|
if strings.Contains(err.Error(), "not found") {
|
|
status = http.StatusNotFound
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
|
|
}
|
|
|
|
func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[API] Optional config update requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
|
|
r.logger.Printf("[API] Optional config update failed for %s: %v", name, err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
|
|
}
|
|
|
|
func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
|
|
lines := 100
|
|
if v := req.URL.Query().Get("lines"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
lines = n
|
|
}
|
|
}
|
|
|
|
output, err := r.stackMgr.GetLogs(name, lines)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
|
|
}
|
|
|
|
func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
|
|
resp, err := r.stackMgr.GetStackHDDData(name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
|
}
|
|
|
|
func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
|
return
|
|
}
|
|
|
|
// Compute the drive path for this stack (HDD or system data path)
|
|
var drivePath string
|
|
if r.crossDriveRunner != nil {
|
|
drivePath = r.crossDriveRunner.GetAppDrivePath(name)
|
|
}
|
|
|
|
resp, err := r.stackMgr.GetStackBackupData(name, drivePath)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
|
}
|
|
|
|
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
|
return
|
|
}
|
|
limitBody(w, req)
|
|
r.logger.Printf("[API] Remove requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
RemoveHDDData bool `json:"remove_hdd_data"`
|
|
RemoveBackups bool `json:"remove_backups"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
body.RemoveHDDData = false
|
|
body.RemoveBackups = false
|
|
}
|
|
|
|
// Compute backup paths to remove if requested
|
|
var backupPaths []string
|
|
if body.RemoveBackups && r.crossDriveRunner != nil {
|
|
drivePath := r.crossDriveRunner.GetAppDrivePath(name)
|
|
if drivePath != "" {
|
|
backupPaths = append(backupPaths,
|
|
backup.AppDBDumpPath(drivePath, name),
|
|
backup.AppSecondaryRsyncPath(drivePath, name),
|
|
)
|
|
}
|
|
}
|
|
|
|
resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
|
|
if err != nil {
|
|
r.logger.Printf("[API] Remove failed for %s: %v", name, err)
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "protected") {
|
|
status = http.StatusForbidden
|
|
}
|
|
if strings.Contains(err.Error(), "not found") {
|
|
status = http.StatusNotFound
|
|
}
|
|
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
|
|
status = http.StatusConflict
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
// Clean up cross-drive backup config for this stack
|
|
if r.sett != nil {
|
|
if err := r.sett.SetCrossDriveConfig(name, nil); err != nil {
|
|
r.logger.Printf("[WARN] Failed to clean cross-drive config for %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
|
|
|
|
// Push app removed event to Hub
|
|
if r.notifier != nil {
|
|
r.notifier.NotifyAppRemoved(name, name)
|
|
}
|
|
}
|
|
|
|
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[API] Delete requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
RemoveHDDData bool `json:"remove_hdd_data"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
body.RemoveHDDData = false
|
|
}
|
|
|
|
resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData)
|
|
if err != nil {
|
|
r.logger.Printf("[API] Delete failed for %s: %v", name, err)
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "protected") {
|
|
status = http.StatusForbidden
|
|
}
|
|
if strings.Contains(err.Error(), "not found") {
|
|
status = http.StatusNotFound
|
|
}
|
|
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
|
|
status = http.StatusConflict
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"})
|
|
}
|
|
|
|
func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
|
|
r.logger.Println("[API] Manual catalog sync requested")
|
|
result := r.syncer.TriggerSync()
|
|
if !result.OK {
|
|
writeJSON(w, http.StatusTooManyRequests, apiResponse{OK: false, Error: result.Message})
|
|
return
|
|
}
|
|
r.logger.Printf("[API] Catalog sync completed: %s", result.Message)
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: result.Message, Data: result})
|
|
}
|
|
|
|
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
|
|
info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector)
|
|
syncStatus := r.syncer.Status()
|
|
data := map[string]interface{}{
|
|
"system": info,
|
|
"sync_status": syncStatus,
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
// --- Backup handlers ---
|
|
|
|
func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"enabled": false,
|
|
}})
|
|
return
|
|
}
|
|
|
|
dbDump, backupSt := r.backupMgr.GetStatus()
|
|
data := map[string]interface{}{
|
|
"enabled": true,
|
|
"running": r.backupMgr.IsRunning(),
|
|
}
|
|
|
|
if dbDump != nil {
|
|
data["db_dump"] = map[string]interface{}{
|
|
"last_run": dbDump.LastRun,
|
|
"success": dbDump.Success,
|
|
"duration": dbDump.Duration.String(),
|
|
"count": len(dbDump.Results),
|
|
}
|
|
}
|
|
|
|
if backupSt != nil {
|
|
backupData := map[string]interface{}{
|
|
"last_run": backupSt.LastRun,
|
|
"success": backupSt.Success,
|
|
"duration": backupSt.Duration.String(),
|
|
}
|
|
if backupSt.Snapshot != nil {
|
|
backupData["snapshot_id"] = backupSt.Snapshot.SnapshotID
|
|
backupData["files_new"] = backupSt.Snapshot.FilesNew
|
|
backupData["data_added"] = backupSt.Snapshot.DataAdded
|
|
}
|
|
if backupSt.RepoStats != nil {
|
|
backupData["repo_size"] = backupSt.RepoStats.TotalSize
|
|
backupData["snapshot_count"] = backupSt.RepoStats.SnapshotCount
|
|
}
|
|
data["backup"] = backupData
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
|
return
|
|
}
|
|
|
|
if r.backupMgr.IsRunning() {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
|
return
|
|
}
|
|
|
|
r.logger.Println("[API] Manual backup triggered")
|
|
go r.backupMgr.RunFullBackup(context.Background())
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
|
}
|
|
|
|
func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
|
|
return
|
|
}
|
|
|
|
snapshots, err := r.backupMgr.ListAllSnapshots(50)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
// Enrich snapshots with drive labels from storage paths
|
|
if r.sett != nil {
|
|
storagePaths := r.sett.GetStoragePaths()
|
|
for i := range snapshots {
|
|
repoPath := snapshots[i].RepoPath
|
|
for _, sp := range storagePaths {
|
|
if strings.HasPrefix(repoPath, sp.Path) {
|
|
snapshots[i].DriveLabel = sp.Label
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if snapshots == nil {
|
|
snapshots = []backup.SnapshotInfo{}
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
|
|
}
|
|
|
|
// --- Metrics handlers ---
|
|
|
|
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}, "temp": []float64{}, "load1": []float64{}}})
|
|
return
|
|
}
|
|
|
|
from, to := parseTimeRange(req)
|
|
resolution := parseResolution(req, 200)
|
|
|
|
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
// Flatten into arrays for Chart.js
|
|
labels := make([]int64, len(samples))
|
|
cpu := make([]float64, len(samples))
|
|
memory := make([]float64, len(samples))
|
|
temp := make([]float64, len(samples))
|
|
load1 := make([]float64, len(samples))
|
|
|
|
for i, s := range samples {
|
|
labels[i] = s.Timestamp
|
|
cpu[i] = s.CPUPercent
|
|
memory[i] = float64(s.MemUsedMB) / 1024.0 // Convert to GB
|
|
temp[i] = s.TempCelsius
|
|
load1[i] = s.LoadAvg1
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"labels": labels,
|
|
"cpu": cpu,
|
|
"memory": memory,
|
|
"temp": temp,
|
|
"load1": load1,
|
|
}})
|
|
}
|
|
|
|
func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
|
|
return
|
|
}
|
|
|
|
summary, err := r.metricsStore.QueryContainerSummary()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: summary})
|
|
}
|
|
|
|
func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name string) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}}})
|
|
return
|
|
}
|
|
|
|
from, to := parseTimeRange(req)
|
|
resolution := parseResolution(req, 150)
|
|
|
|
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
labels := make([]int64, len(samples))
|
|
cpu := make([]float64, len(samples))
|
|
memory := make([]float64, len(samples))
|
|
|
|
for i, s := range samples {
|
|
labels[i] = s.Timestamp
|
|
cpu[i] = s.CPUPercent
|
|
memory[i] = s.MemUsageMB
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"labels": labels,
|
|
"cpu": cpu,
|
|
"memory": memory,
|
|
}})
|
|
}
|
|
|
|
func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
|
|
info := metrics.GetStaticInfo()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
|
|
}
|
|
|
|
// --- Cross-drive backup handlers ---
|
|
|
|
func (r *Router) saveCrossBackupConfig(w http.ResponseWriter, req *http.Request, name string) {
|
|
if r.crossDriveRunner == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
|
return
|
|
}
|
|
limitBody(w, req)
|
|
|
|
var body struct {
|
|
Enabled bool `json:"enabled"`
|
|
DestinationPath string `json:"destination_path"`
|
|
Schedule string `json:"schedule"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
// Validate schedule
|
|
if body.Schedule != "daily" && body.Schedule != "weekly" && body.Schedule != "manual" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "schedule must be 'daily', 'weekly', or 'manual'"})
|
|
return
|
|
}
|
|
// C9: Validate DestinationPath against registered storage paths to prevent path traversal.
|
|
if body.Enabled && body.DestinationPath != "" {
|
|
registeredPaths := r.sett.GetStoragePaths()
|
|
validDest := false
|
|
for _, sp := range registeredPaths {
|
|
if body.DestinationPath == sp.Path {
|
|
validDest = true
|
|
break
|
|
}
|
|
}
|
|
if !validDest {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "destination_path must be a registered storage path"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Preserve existing runtime status
|
|
existing := r.sett.GetCrossDriveConfig(name)
|
|
var lastRun, lastStatus, lastError, lastDuration, lastSize string
|
|
if existing != nil {
|
|
lastRun, lastStatus, lastError, lastDuration, lastSize =
|
|
existing.LastRun, existing.LastStatus, existing.LastError, existing.LastDuration, existing.LastSizeHuman
|
|
}
|
|
|
|
cfg := &settings.CrossDriveBackup{
|
|
Enabled: body.Enabled,
|
|
Method: "rsync",
|
|
DestinationPath: body.DestinationPath,
|
|
Schedule: body.Schedule,
|
|
LastRun: lastRun,
|
|
LastStatus: lastStatus,
|
|
LastError: lastError,
|
|
LastDuration: lastDuration,
|
|
LastSizeHuman: lastSize,
|
|
}
|
|
|
|
if err := r.sett.SetCrossDriveConfig(name, cfg); err != nil {
|
|
r.logger.Printf("[API] Failed to save cross-drive config for %s: %v", name, err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
r.logger.Printf("[API] Cross-drive backup config saved for %s: dest=%s schedule=%s",
|
|
name, body.DestinationPath, body.Schedule)
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Cross-drive backup configuration saved"})
|
|
}
|
|
|
|
func (r *Router) triggerCrossBackup(w http.ResponseWriter, req *http.Request, name string) {
|
|
if r.crossDriveRunner == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
|
return
|
|
}
|
|
if r.crossDriveRunner.IsRunning(name) {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
|
return
|
|
}
|
|
|
|
r.logger.Printf("[API] Cross-drive backup triggered for: %s", name)
|
|
go func() {
|
|
if err := r.crossDriveRunner.RunAppBackup(context.Background(), name); err != nil {
|
|
r.logger.Printf("[API] Cross-drive backup failed for %s: %v", name, err)
|
|
}
|
|
}()
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
|
}
|
|
|
|
func (r *Router) getCrossBackupStatus(w http.ResponseWriter, _ *http.Request, name string) {
|
|
cfg := r.sett.GetCrossDriveConfig(name)
|
|
if cfg == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"configured": false}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"configured": true,
|
|
"enabled": cfg.Enabled,
|
|
"method": "rsync",
|
|
"schedule": cfg.Schedule,
|
|
"running": r.crossDriveRunner != nil && r.crossDriveRunner.IsRunning(name),
|
|
"last_run": cfg.LastRun,
|
|
"last_status": cfg.LastStatus,
|
|
"last_error": cfg.LastError,
|
|
"last_duration": cfg.LastDuration,
|
|
"last_size": cfg.LastSizeHuman,
|
|
}})
|
|
}
|
|
|
|
func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request) {
|
|
if r.crossDriveRunner == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
|
return
|
|
}
|
|
r.logger.Println("[API] All cross-drive backups triggered")
|
|
go func() {
|
|
ctx := context.Background()
|
|
if err := r.crossDriveRunner.RunAllScheduled(ctx, "daily"); err != nil {
|
|
r.logger.Printf("[API] Cross-drive run-all error: %v", err)
|
|
}
|
|
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
|
|
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
|
|
}
|
|
if err := r.crossDriveRunner.RunAllScheduled(ctx, "manual"); err != nil {
|
|
r.logger.Printf("[API] Cross-drive run-all manual error: %v", err)
|
|
}
|
|
}()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Összes mentés elindítva"})
|
|
}
|
|
|
|
// parseTimeRange reads range or from/to query params.
|
|
func parseTimeRange(req *http.Request) (from, to time.Time) {
|
|
to = time.Now()
|
|
|
|
if rangeStr := req.URL.Query().Get("range"); rangeStr != "" {
|
|
switch rangeStr {
|
|
case "1h":
|
|
from = to.Add(-1 * time.Hour)
|
|
case "6h":
|
|
from = to.Add(-6 * time.Hour)
|
|
case "24h":
|
|
from = to.Add(-24 * time.Hour)
|
|
case "7d":
|
|
from = to.Add(-7 * 24 * time.Hour)
|
|
case "30d":
|
|
from = to.Add(-30 * 24 * time.Hour)
|
|
default:
|
|
from = to.Add(-24 * time.Hour) // default 24h
|
|
}
|
|
return
|
|
}
|
|
|
|
if fromStr := req.URL.Query().Get("from"); fromStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
|
|
from = t
|
|
}
|
|
}
|
|
if toStr := req.URL.Query().Get("to"); toStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, toStr); err == nil {
|
|
to = t
|
|
}
|
|
}
|
|
if from.IsZero() {
|
|
from = to.Add(-24 * time.Hour)
|
|
}
|
|
return
|
|
}
|
|
|
|
// parseResolution reads the resolution query param.
|
|
func parseResolution(req *http.Request, defaultVal int) int {
|
|
if v := req.URL.Query().Get("resolution"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) }
|
|
|
|
func hasSubpath(path, prefix string) bool {
|
|
rest := strings.TrimPrefix(path, prefix)
|
|
return strings.Contains(rest, "/")
|
|
}
|
|
|
|
func trimSegment(path, prefix string) string {
|
|
return strings.TrimPrefix(path, prefix)
|
|
}
|
|
|
|
func extractName(path, suffix string) string {
|
|
s := strings.TrimPrefix(path, "/stacks/")
|
|
name := strings.TrimSuffix(s, suffix)
|
|
// C7: Reject path traversal characters — name is used in file paths and Docker commands.
|
|
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
|
|
return ""
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (r *Router) selfupdateStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"enabled": false}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.updater.GetStatus()})
|
|
}
|
|
|
|
func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
|
return
|
|
}
|
|
result := r.updater.CheckForUpdate()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
|
|
}
|
|
|
|
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
|
return
|
|
}
|
|
if err := r.updater.TriggerUpdate("manual"); err != nil {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
r.logger.Println("[API] Manual self-update triggered")
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
|
|
}
|
|
|
|
// --- Config apply handler ---
|
|
|
|
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
|
// Read YAML body (limit to 1MB)
|
|
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
|
|
return
|
|
}
|
|
if len(body) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
|
|
return
|
|
}
|
|
|
|
// Validate it's parseable YAML by attempting to load it
|
|
if _, err := config.LoadFromBytes(body); err != nil {
|
|
r.logger.Printf("[API] Config apply rejected: invalid YAML: %v", err)
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
|
|
return
|
|
}
|
|
|
|
// Write config: try atomic rename first, fall back to direct write
|
|
// (os.Rename fails on Docker bind mounts with "device or resource busy")
|
|
tmpPath := r.configPath + ".tmp"
|
|
if err := os.WriteFile(tmpPath, body, 0644); err != nil {
|
|
r.logger.Printf("[ERROR] Config apply: failed to write temp file: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to write config"})
|
|
return
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, r.configPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
// Rename failed (likely Docker bind mount) — write directly
|
|
if err := os.WriteFile(r.configPath, body, 0644); err != nil {
|
|
r.logger.Printf("[ERROR] Config apply: failed to write config: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
|
|
return
|
|
}
|
|
r.logger.Printf("[API] Config apply: rename failed, wrote directly (bind mount)")
|
|
}
|
|
|
|
r.logger.Printf("[API] Config applied from Hub (%d bytes), restart needed to take effect", len(body))
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Config applied. Restart controller to apply changes."})
|
|
|
|
// Push updated infra backup so Hub has fresh config data immediately
|
|
if r.OnConfigApplied != nil {
|
|
go r.OnConfigApplied()
|
|
}
|
|
}
|
|
|
|
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
|
|
hash, err := config.FileHash(r.configPath)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
|
|
}
|
|
|
|
func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
|
|
data, err := os.ReadFile(r.configPath)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
|
w.Write(data)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
log.Printf("[ERROR] Failed to write JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// limitBody wraps the request body with a size limit (default 1MB).
|
|
func limitBody(w http.ResponseWriter, req *http.Request) {
|
|
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
|
|
}
|