95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1258 lines
42 KiB
Go
1258 lines
42 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/assets"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
|
"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()
|
|
|
|
// OnCrossDriveComplete is called after a manual cross-drive backup completes (to push infra backup to Hub).
|
|
OnCrossDriveComplete func()
|
|
|
|
// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
|
|
OnGeoRelevantChange func()
|
|
|
|
// Asset syncer for on-demand Hub asset sync
|
|
assetsSyncer *assets.Syncer
|
|
|
|
// Geo-restriction sync manager
|
|
geoSync *cf.GeoSyncManager
|
|
|
|
// App-to-app integration manager (nil if not configured)
|
|
integrationMgr *integrations.Manager
|
|
|
|
debug bool
|
|
}
|
|
|
|
// SetDebug enables or disables debug logging for API routing.
|
|
func (r *Router) SetDebug(on bool) {
|
|
r.debug = on
|
|
}
|
|
|
|
func (r *Router) dbg(format string, args ...interface{}) {
|
|
if r.debug {
|
|
r.logger.Printf("[DEBUG] [api] "+format, args...)
|
|
}
|
|
}
|
|
|
|
// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
|
|
func (r *Router) SetAssetsSyncer(as *assets.Syncer) {
|
|
r.assetsSyncer = as
|
|
}
|
|
|
|
// SetGeoSync sets the geo-restriction sync manager.
|
|
func (r *Router) SetGeoSync(gs *cf.GeoSyncManager) {
|
|
r.geoSync = gs
|
|
}
|
|
|
|
// SetIntegrationManager sets the app-to-app integration manager.
|
|
func (r *Router) SetIntegrationManager(im *integrations.Manager) {
|
|
r.integrationMgr = im
|
|
}
|
|
|
|
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, "/")
|
|
|
|
r.dbg("%s %s (path=%s)", req.Method, req.URL.Path, 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)
|
|
|
|
// --- Integration routes (must be before hasSuffix-based stack cases) ---
|
|
|
|
// GET /api/integrations/{provider} — list integrations for a provider
|
|
case strings.HasPrefix(path, "/integrations/") && !strings.Contains(strings.TrimPrefix(path, "/integrations/"), "/") && req.Method == http.MethodGet:
|
|
provider := strings.TrimPrefix(path, "/integrations/")
|
|
r.listIntegrations(w, provider)
|
|
|
|
// POST /api/integrations/{provider}/{target} — toggle integration
|
|
case strings.HasPrefix(path, "/integrations/") && req.Method == http.MethodPost:
|
|
rest := strings.TrimPrefix(path, "/integrations/")
|
|
parts := strings.SplitN(rest, "/", 2)
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid integration path"})
|
|
return
|
|
}
|
|
r.toggleIntegration(w, req, parts[0], parts[1])
|
|
|
|
// 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)
|
|
|
|
// POST /api/assets/sync — trigger immediate asset sync from Hub
|
|
case path == "/assets/sync" && req.Method == http.MethodPost:
|
|
r.triggerAssetSync(w, req)
|
|
|
|
// GET /api/assets/status — get asset sync status
|
|
case path == "/assets/status" && req.Method == http.MethodGet:
|
|
r.assetSyncStatus(w, req)
|
|
|
|
// --- Geo-restriction endpoints ---
|
|
|
|
// GET /api/geo/status — current geo settings + sync state
|
|
case path == "/geo/status" && req.Method == http.MethodGet:
|
|
r.geoStatus(w, req)
|
|
|
|
// POST /api/geo/settings — update global geo settings
|
|
case path == "/geo/settings" && req.Method == http.MethodPost:
|
|
r.geoUpdateSettings(w, req)
|
|
|
|
// POST /api/geo/sync — trigger manual Cloudflare sync
|
|
case path == "/geo/sync" && req.Method == http.MethodPost:
|
|
r.geoTriggerSync(w, req)
|
|
|
|
// GET /api/geo/countries — full country list for search UI
|
|
case path == "/geo/countries" && req.Method == http.MethodGet:
|
|
r.geoCountries(w, req)
|
|
|
|
// POST /api/stacks/{name}/geo/override — set per-app geo override
|
|
case hasSuffix(path, "/geo/override") && req.Method == http.MethodPost:
|
|
r.geoSetAppOverride(w, req, extractName(path, "/geo/override"))
|
|
|
|
// DELETE /api/stacks/{name}/geo/override — remove per-app geo override
|
|
case hasSuffix(path, "/geo/override") && req.Method == http.MethodDelete:
|
|
r.geoRemoveAppOverride(w, req, extractName(path, "/geo/override"))
|
|
|
|
default:
|
|
r.dbg("no matching route: %s %s", req.Method, path)
|
|
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)
|
|
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
|
|
|
|
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)
|
|
}
|
|
|
|
// Re-sync geo rules (new hostname may need to be added)
|
|
if r.OnGeoRelevantChange != nil {
|
|
go r.OnGeoRelevantChange()
|
|
}
|
|
|
|
// Re-apply integrations that target this newly deployed stack
|
|
if r.integrationMgr != nil {
|
|
go r.integrationMgr.OnStackStart(context.Background(), name)
|
|
}
|
|
}
|
|
|
|
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
|
r.logger.Printf("[API] %s requested for stack: %s", action, name)
|
|
r.dbg("actionStack: action=%s name=%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
|
|
}
|
|
|
|
// Memory check before starting a stopped app
|
|
if action == "start" {
|
|
stackMemMB := r.stackMgr.StackMemoryMB(name)
|
|
if stackMemMB > 0 {
|
|
if totalMB, usedMB, memErr := system.GetMemoryMB(); memErr == nil {
|
|
reservedMB := r.cfg.System.ReservedMemoryMB
|
|
usableMB := totalMB - reservedMB
|
|
if usableMB < 0 {
|
|
usableMB = 0
|
|
}
|
|
afterMB := usedMB + stackMemMB
|
|
if afterMB > usableMB {
|
|
writeJSON(w, http.StatusConflict, apiResponse{
|
|
OK: false,
|
|
Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
|
|
})
|
|
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"})
|
|
|
|
// Trigger integration lifecycle hooks after successful action
|
|
if r.integrationMgr != nil {
|
|
switch action {
|
|
case "start", "restart":
|
|
go r.integrationMgr.OnStackStart(context.Background(), name)
|
|
case "stop":
|
|
go r.integrationMgr.OnStackStop(context.Background(), name)
|
|
}
|
|
}
|
|
}
|
|
|
|
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"})
|
|
}
|
|
|
|
// --- Integration API handlers ---
|
|
|
|
func (r *Router) listIntegrations(w http.ResponseWriter, provider string) {
|
|
if r.integrationMgr == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []integrations.StatusInfo{}})
|
|
return
|
|
}
|
|
list := r.integrationMgr.ListForProvider(provider)
|
|
if list == nil {
|
|
list = []integrations.StatusInfo{}
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
|
|
}
|
|
|
|
func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, provider, target string) {
|
|
limitBody(w, req)
|
|
if r.integrationMgr == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, apiResponse{OK: false, Error: "integrations not available"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
action := "enable"
|
|
if !body.Enabled {
|
|
action = "disable"
|
|
}
|
|
r.logger.Printf("[API] Integration %s requested: %s:%s", action, provider, target)
|
|
|
|
state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled)
|
|
if err != nil {
|
|
r.logger.Printf("[API] Integration toggle failed for %s:%s: %v", provider, target, err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
msg := "Integráció engedélyezve"
|
|
if !body.Enabled {
|
|
msg = "Integráció kikapcsolva"
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
|
|
}
|
|
|
|
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
|
|
if lines > 10000 {
|
|
lines = 10000
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
r.dbg("removeStack: name=%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
|
|
}
|
|
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Clean up integrations for removed stack
|
|
if r.integrationMgr != nil {
|
|
r.integrationMgr.OnStackRemove(context.Background(), name)
|
|
}
|
|
|
|
// Re-sync geo rules (hostname removed)
|
|
if r.OnGeoRelevantChange != nil {
|
|
go r.OnGeoRelevantChange()
|
|
}
|
|
}
|
|
|
|
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) {
|
|
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
|
return
|
|
}
|
|
|
|
if r.backupMgr.IsRunning() {
|
|
r.dbg("triggerBackup: backup already running, rejecting")
|
|
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)
|
|
}
|
|
if r.OnCrossDriveComplete != nil {
|
|
r.OnCrossDriveComplete()
|
|
}
|
|
}()
|
|
|
|
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 r.OnCrossDriveComplete != nil {
|
|
r.OnCrossDriveComplete()
|
|
}
|
|
}()
|
|
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) {
|
|
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
|
|
// 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)
|
|
}
|
|
|
|
// --- Asset sync handlers ---
|
|
|
|
func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
|
|
if r.assetsSyncer == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: false, Error: "asset sync not configured"})
|
|
return
|
|
}
|
|
r.logger.Println("[API] Manual asset sync requested")
|
|
go func() {
|
|
if err := r.assetsSyncer.Sync(context.Background()); err != nil {
|
|
r.logger.Printf("[WARN] Manual asset sync failed: %v", err)
|
|
}
|
|
}()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Asset sync started"})
|
|
}
|
|
|
|
func (r *Router) assetSyncStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.assetsSyncer == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"status": "not_configured"}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.assetsSyncer.Status()})
|
|
}
|
|
|
|
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
|
|
}
|