Files
deploy-felhom-compose/controller/internal/api/router.go
T
admin 62992e0e04 v0.12.2: restore section simplification — snapshot filtering, auto-stop/restart, UI cleanup
- StackDataProvider interface extended with StopStack/StartStack
- backup.Manager.GetStackHDDMounts() delegates to stackProvider
- RestoreApp() auto-stops app before restic restore, restarts after (even on failure)
- stackAdapter in main.go wires StopStack/StartStack through to stacks.Manager
- GET /api/backup/snapshots?stack={name} filters snapshots by app HDD paths via filterSnapshotsByPaths()
- Restore section simplified: no path list, per-app filtered snapshots, human-friendly timestamp format, single calm warning, empty-result inline message

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 19:19:23 +01:00

786 lines
26 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"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/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
sett *settings.Settings
stackMgr *stacks.Manager
syncer *catalogsync.Syncer
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
crossDriveRunner *backup.CrossDriveRunner
metricsStore *metrics.MetricsStore
logger *log.Logger
}
func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, logger *log.Logger) *Router {
return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, 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/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"))
// 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)
}
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) 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.ListSnapshots(50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
// Filter by stack if requested — only return snapshots that include the app's HDD paths.
if stackName := req.URL.Query().Get("stack"); stackName != "" {
mounts := r.backupMgr.GetStackHDDMounts(stackName)
if len(mounts) > 0 {
snapshots = filterSnapshotsByPaths(snapshots, mounts)
}
}
if snapshots == nil {
snapshots = []backup.SnapshotInfo{}
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
}
// filterSnapshotsByPaths returns only snapshots whose Paths overlap with requiredPaths.
// A snapshot matches if any of its paths is a prefix of (or prefixed by) any required path.
func filterSnapshotsByPaths(snapshots []backup.SnapshotInfo, requiredPaths []string) []backup.SnapshotInfo {
var filtered []backup.SnapshotInfo
outer:
for _, snap := range snapshots {
for _, required := range requiredPaths {
for _, sp := range snap.Paths {
if strings.HasPrefix(required, sp) || strings.HasPrefix(sp, required) {
filtered = append(filtered, snap)
continue outer
}
}
}
}
return filtered
}
// --- 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"`
Method string `json:"method"`
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 method
if body.Method != "rsync" && body.Method != "restic" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "method must be 'rsync' or 'restic'"})
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
}
// 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: body.Method,
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: method=%s dest=%s schedule=%s",
name, body.Method, 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": cfg.Method,
"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/")
return strings.TrimSuffix(s, suffix)
}
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
}