Compare commits
4 Commits
2a2514255b
...
c514c0042b
| Author | SHA1 | Date | |
|---|---|---|---|
| c514c0042b | |||
| 09d75c1e2e | |||
| 02a4ba0491 | |||
| ba87412508 |
@@ -1,5 +1,33 @@
|
||||
## Changelog
|
||||
|
||||
### v0.70.0 — config-apply self-restart + geo-restriction UX fixes (2026-06-16)
|
||||
|
||||
Fixes found during live geo testing (rotating the Cloudflare API token).
|
||||
|
||||
- **Config-apply now self-restarts (core fix).** `POST /api/config/apply` previously wrote the new
|
||||
`controller.yaml` but logged "restart needed" and left stale in-process singletons — the Cloudflare
|
||||
client is built once at startup, so a rotated CF token kept 403'ing until a manual LXC restart. Now:
|
||||
if the pushed config is byte-identical to the current one, do nothing (no flap on idempotent
|
||||
re-push); otherwise write, respond 200 (flushed), then **gracefully self-restart** (`os.Exit(0)` after
|
||||
~500ms; the container is `restart: unless-stopped`, so it comes back with fresh config). The exit is
|
||||
behind an injectable `Restarter` seam (`Router.restart`/`SetRestarter`) for unit testing. Removed the
|
||||
stale "restart needed" wording and the dead `OnConfigApplied` hook (Phase-1-retired infra-backup push).
|
||||
- **Manual "Vezérlő újraindítása" button** on the settings page → `POST /api/selfrestart` (auth + CSRF
|
||||
via the `/api/` mount) using the same helper. Confirm dialog → POST → polls `GET /` every 2s until the
|
||||
controller answers → reloads. Self-serve restart without rebooting the whole guest.
|
||||
- **Immediate hub report push on geo change.** A successful geo settings save and a successful manual
|
||||
geo sync now fire an out-of-band, non-blocking report push (`Router.reportPushNow`), so the hub
|
||||
reflects the new geo state / clears a stale `last_sync_error` within seconds instead of after the
|
||||
next ~15-min cycle. (Pattern can extend to other settings later; scoped to geo handlers for now.)
|
||||
- **Always report `geo_restriction`.** `BuildReport` now always populates the field (Enabled=false,
|
||||
empty countries when never configured) instead of omitting it when nil — so the hub always renders
|
||||
the geo section ("Inaktív" when off) rather than hiding it.
|
||||
- **Country autocomplete fixed.** Root cause (diagnosed live): `filterCountries` populated the list
|
||||
correctly but revealed it with `style.display = ''`; the `.geo-country-list` CSS default is
|
||||
`display:none`, so clearing the inline style kept the populated dropdown hidden — no console error,
|
||||
just an invisible list. Latent since the geo feature's first commit (not the hypothesised JS throw).
|
||||
Fix: reveal with `display = 'block'`.
|
||||
|
||||
### v0.69.0 — remove dead infra-backup stubs + the unused restic-password report field (2026-06-16)
|
||||
|
||||
Controller half of the Phase-1 Infra Backup retirement (hub v0.12.0; see
|
||||
|
||||
+13
-1
@@ -1215,15 +1215,27 @@ Five sections:
|
||||
Periodic JSON push (default every 15 min) to the central felhom-hub service:
|
||||
- System: hostname, OS, CPU, memory, disk usage, uptime
|
||||
- Containers: running/stopped counts, per-container CPU/memory
|
||||
- Backup: last run, success, repo stats, snapshot count, restic password (for disaster recovery)
|
||||
- Backup: last DB-dump run, success (disk-tier backup is the host agent's; restic password is no longer reported — removed v0.69.0)
|
||||
- Health: current status, issues, warnings
|
||||
- Stacks: deployed apps with versions and states
|
||||
- Config hash: SHA256 of `controller.yaml` for Hub-side config comparison
|
||||
- **Geo-restriction (always present, v0.70.0):** `geo_restriction` is always populated — `Enabled=false` with an empty country list when never configured — so the Hub always renders the geo section ("Inaktív" when off) instead of hiding it. `buildGeoRestrictionReport` in `internal/report/builder.go`.
|
||||
- **App telemetry** (v0.28.0+): Per-stack memory (current/avg/peak) and CPU averages from the last 15 minutes of metrics data, plus log scan results (error/warning counts with deduplicated issues). Only non-protected, deployed stacks are included. Backward-compatible: old Hub versions silently ignore this field.
|
||||
- **Controller telemetry** (v0.32.4+): The controller's own container (`felhom-controller`) is included as a special entry in the `app_telemetry` array. Its memory/CPU metrics come from the same metrics collector, and its log warnings/errors are scanned via `docker logs` using the same pipeline as app containers. This reuses all existing Hub telemetry infrastructure (memory trend charts, known issues, fleet aggregation) with zero Hub-side changes.
|
||||
|
||||
Bearer token authentication, 3-attempt retry with 5-second backoff. Push status tracked via `PushStatus` struct (LastAttempt, LastSuccess, LastError, consecutive failures) — used by the monitoring page and alert system to show Hub connection health.
|
||||
|
||||
**Immediate report push on geo change (v0.70.0):** besides the periodic cycle, a successful geo settings save and a successful manual geo sync fire an **out-of-band, non-blocking** report push (`Router.reportPushNow`, wired in `main.go` to `BuildReport`+`Push` in a goroutine), so the Hub reflects the new geo state / clears a stale `last_sync_error` within seconds instead of after the next ~15-min cycle. Currently scoped to the geo handlers; the same seam can be reused for other settings later.
|
||||
|
||||
#### Config apply + self-restart (`internal/api/router.go`, `internal/api/selfrestart.go`)
|
||||
|
||||
`POST /api/config/apply` (Hub-authed) writes a new `controller.yaml`, but the new config only takes effect on **restart** — singletons such as the Cloudflare client are built once at startup (so a rotated CF API token would otherwise keep failing). Behaviour (v0.70.0):
|
||||
|
||||
- **No-op guard:** if the pushed body is byte-identical to the current file, do nothing — no rewrite, no restart (the Hub may re-push idempotently; never flap on a no-op).
|
||||
- Otherwise: write `0600`, respond `200` (flushed), then **gracefully self-restart** — `gracefulSelfRestart` waits ~500 ms for the response to flush, then `os.Exit(0)`. The container runs `restart: unless-stopped`, so Docker brings it back with the fresh config, and a startup report is pushed. The exit sits behind an injectable seam (`Router.restart` / `SetRestarter`) for unit testing.
|
||||
|
||||
**Manual restart (v0.70.0):** `POST /api/selfrestart` (session auth + CSRF via the `/api/` mount) runs the same helper — surfaced as the **"Vezérlő újraindítása"** button on the settings page (confirm → POST → poll `GET /` every 2 s → reload), so a customer can recover the controller without rebooting the whole guest.
|
||||
|
||||
#### App Telemetry (`internal/metrics/telemetry.go`, `internal/metrics/logscanner.go`, `internal/report/telemetry.go`)
|
||||
|
||||
Each report push now includes per-app telemetry data:
|
||||
|
||||
@@ -574,10 +574,16 @@ func main() {
|
||||
// --- Initialize API router ---
|
||||
apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger)
|
||||
if hubPusher != nil {
|
||||
apiRouter.OnConfigApplied = func() {
|
||||
// Infra backup push is now the host agent's responsibility; the controller
|
||||
// only refreshes the Hub report after a config apply.
|
||||
}
|
||||
// Out-of-band, non-blocking hub report push (e.g. after a geo settings change) so
|
||||
// the hub reflects the new state immediately instead of after the next ~15-min cycle.
|
||||
apiRouter.SetReportPushTrigger(func() {
|
||||
go func() {
|
||||
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
||||
if err := hubPusher.Push(rep); err != nil {
|
||||
logger.Printf("[WARN] [report] Out-of-band geo report push failed: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
if assetsSyncer != nil {
|
||||
apiRouter.SetAssetsSyncer(assetsSyncer)
|
||||
|
||||
@@ -82,6 +82,10 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
|
||||
}()
|
||||
}
|
||||
|
||||
// Push a fresh hub report out-of-band so the hub reflects the new geo state right
|
||||
// away instead of after the next ~15-min cycle.
|
||||
r.reportPushNow()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Geo-korlátozás beállítva"})
|
||||
}
|
||||
|
||||
@@ -94,7 +98,11 @@ func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
go func() {
|
||||
if err := r.geoSync.Sync(context.Background()); err != nil {
|
||||
r.logger.Printf("[ERROR] [api] Manual geo sync failed: %v", err)
|
||||
return
|
||||
}
|
||||
// On success the sync clears any stale last_sync_error — push a report so the hub
|
||||
// reflects the cleared state immediately rather than after the next cycle.
|
||||
r.reportPushNow()
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
func newGeoTestRouter(t *testing.T) (*Router, *int) {
|
||||
t.Helper()
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("settings.Load: %v", err)
|
||||
}
|
||||
calls := 0
|
||||
r := &Router{cfg: &config.Config{}, sett: sett, logger: log.New(io.Discard, "", 0)}
|
||||
r.triggerReportPush = func() { calls++ }
|
||||
return r, &calls
|
||||
}
|
||||
|
||||
func postGeoSettings(t *testing.T, r *Router, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/geo/settings", bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
r.geoUpdateSettings(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestGeoUpdateSettings_Success_PushesReport(t *testing.T) {
|
||||
r, calls := newGeoTestRouter(t)
|
||||
rec := postGeoSettings(t, r, `{"enabled":true,"allowed_countries":["HU"]}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("report push called %d times, want 1 (successful save)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// COMPANION: a validation-failed save (invalid country code) must NOT push a report.
|
||||
func TestGeoUpdateSettings_InvalidCountry_NoPush(t *testing.T) {
|
||||
r, calls := newGeoTestRouter(t)
|
||||
rec := postGeoSettings(t, r, `{"enabled":true,"allowed_countries":["XX"]}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (invalid country code)", rec.Code)
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Fatalf("report push called %d times, want 0 (validation failed before save)", *calls)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -41,8 +42,14 @@ type Router struct {
|
||||
notifier *notify.Notifier
|
||||
logger *log.Logger
|
||||
|
||||
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
|
||||
OnConfigApplied func()
|
||||
// restart triggers a graceful self-restart (config-apply + the manual restart button).
|
||||
// Defaults to a real exit-after-flush so Docker's restart:unless-stopped brings the
|
||||
// process back with fresh config; tests inject a recorder via SetRestarter.
|
||||
restart func()
|
||||
|
||||
// triggerReportPush fires an out-of-band, non-blocking hub report push (e.g. after a
|
||||
// geo settings change so the hub reflects the new state immediately). Nil = no-op.
|
||||
triggerReportPush func()
|
||||
|
||||
// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
|
||||
OnGeoRelevantChange func()
|
||||
@@ -86,7 +93,25 @@ func (r *Router) SetIntegrationManager(im *integrations.Manager) {
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, 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, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
|
||||
r := &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
|
||||
r.restart = func() { gracefulSelfRestart(r.logger) }
|
||||
return r
|
||||
}
|
||||
|
||||
// SetRestarter overrides the graceful-restart action. Tests inject a recorder so the
|
||||
// process is not actually killed.
|
||||
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
|
||||
|
||||
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
|
||||
// The provided func MUST be non-blocking (it is called from request handlers).
|
||||
func (r *Router) SetReportPushTrigger(fn func()) { r.triggerReportPush = fn }
|
||||
|
||||
// reportPushNow fires the report-push trigger if wired. Called after a state change the
|
||||
// hub should reflect immediately (geo settings/sync) instead of waiting for the next cycle.
|
||||
func (r *Router) reportPushNow() {
|
||||
if r.triggerReportPush != nil {
|
||||
r.triggerReportPush()
|
||||
}
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
@@ -146,6 +171,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case path == "/config" && req.Method == http.MethodGet:
|
||||
r.configContent(w, req)
|
||||
|
||||
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
|
||||
case path == "/selfrestart" && req.Method == http.MethodPost:
|
||||
r.selfRestart(w, req)
|
||||
|
||||
// --- Integration routes (must be before hasSuffix-based stack cases) ---
|
||||
|
||||
// GET /api/integrations/{provider} — list integrations for a provider
|
||||
@@ -1058,6 +1087,15 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// No-op guard: if the pushed config is byte-identical to what is already on disk, do
|
||||
// nothing — don't rewrite, don't restart. The hub may re-push the same config
|
||||
// idempotently, and a self-restart on every push would be a needless flap.
|
||||
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
|
||||
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
|
||||
return
|
||||
}
|
||||
|
||||
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
|
||||
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
|
||||
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
|
||||
@@ -1067,12 +1105,14 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[INFO] [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()
|
||||
// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
|
||||
// effect on restart — singletons such as the Cloudflare client are built once at
|
||||
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
|
||||
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
|
||||
flushResponse(w)
|
||||
if r.restart != nil {
|
||||
r.restart()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// restartDelay gives the in-flight HTTP response time to flush before the process exits.
|
||||
const restartDelay = 500 * time.Millisecond
|
||||
|
||||
// gracefulSelfRestart schedules a clean process exit after a short delay. The container
|
||||
// runs with `restart: unless-stopped`, so exiting 0 makes Docker start a fresh process
|
||||
// that re-reads controller.yaml. This is how a config-apply (e.g. a rotated Cloudflare
|
||||
// API token) and the manual restart button actually take effect: singletons such as the
|
||||
// Cloudflare client are built once at startup and are not reloaded in-process.
|
||||
func gracefulSelfRestart(logger *log.Logger) {
|
||||
go func() {
|
||||
time.Sleep(restartDelay)
|
||||
if logger != nil {
|
||||
logger.Println("[INFO] [api] Graceful self-restart: exiting (0) for container restart")
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
// flushResponse flushes buffered output to the client if the writer supports it, so the
|
||||
// caller receives the response body before a subsequent self-restart kills the process.
|
||||
func flushResponse(w http.ResponseWriter) {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// selfRestart handles POST /api/selfrestart — a customer-facing self-serve restart so a
|
||||
// user can recover the controller without rebooting the whole guest. Auth + CSRF are
|
||||
// applied by the /api/ mux mount (same protection as every other state-changing endpoint).
|
||||
// Responds first, flushes, then triggers the graceful restart.
|
||||
func (r *Router) selfRestart(w http.ResponseWriter, _ *http.Request) {
|
||||
r.logger.Println("[INFO] [api] Manual controller restart requested")
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Újraindítás folyamatban…"})
|
||||
flushResponse(w)
|
||||
if r.restart != nil {
|
||||
r.restart()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newRestartTestRouter builds a minimal Router with a recording restart seam (so the
|
||||
// process is never actually killed) and a config path seeded with `prior`.
|
||||
func newRestartTestRouter(t *testing.T, prior []byte) (*Router, *int, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "controller.yaml")
|
||||
if prior != nil {
|
||||
if err := os.WriteFile(path, prior, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
calls := 0
|
||||
r := &Router{configPath: path, logger: log.New(io.Discard, "", 0)}
|
||||
r.restart = func() { calls++ }
|
||||
return r, &calls, path
|
||||
}
|
||||
|
||||
// a minimal config that passes config.LoadFromBytes (customer.id + customer.domain required).
|
||||
var validConfig = []byte("customer:\n id: test\n domain: test.example\n")
|
||||
|
||||
func TestConfigApply_ChangedConfig_Restarts(t *testing.T) {
|
||||
prior := []byte("customer:\n id: old\n domain: old.example\n")
|
||||
r, calls, path := newRestartTestRouter(t, prior)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("restart called %d times, want 1 (config changed)", *calls)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(got, validConfig) {
|
||||
t.Fatalf("config not written: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// COMPANION: an identical re-push must NOT restart (the old handler always restarted /
|
||||
// always ran its post-apply hook regardless of whether anything changed).
|
||||
func TestConfigApply_IdenticalConfig_NoRestart(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, validConfig) // prior == body
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Fatalf("restart called %d times, want 0 (config byte-identical)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigApply_InvalidYAML_NoRestart(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, validConfig)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader([]byte("customer:\n id: only-id-no-domain\n")))
|
||||
rec := httptest.NewRecorder()
|
||||
r.configApply(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (missing customer.domain)", rec.Code)
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Fatalf("restart called %d times, want 0 (validation failed)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfRestart_InvokesRestarter(t *testing.T) {
|
||||
r, calls, _ := newRestartTestRouter(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/selfrestart", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.selfRestart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("restart called %d times, want 1", *calls)
|
||||
}
|
||||
}
|
||||
@@ -155,22 +155,11 @@ func BuildReport(
|
||||
// App telemetry (metrics + log scan)
|
||||
r.AppTelemetry = buildAppTelemetrySection(stackMgr, metricsStore, logger)
|
||||
|
||||
// Geo-restriction status
|
||||
if geoRestriction != nil {
|
||||
gr := &GeoRestrictionReport{
|
||||
Enabled: geoRestriction.Enabled,
|
||||
AllowedCountries: geoRestriction.AllowedCountries,
|
||||
LastSync: geoRestriction.LastSync,
|
||||
LastSyncError: geoRestriction.LastSyncError,
|
||||
}
|
||||
if len(geoRestriction.AppOverrides) > 0 {
|
||||
gr.AppOverrides = make(map[string]GeoAppOverrideReport, len(geoRestriction.AppOverrides))
|
||||
for k, v := range geoRestriction.AppOverrides {
|
||||
gr.AppOverrides[k] = GeoAppOverrideReport{AllowedCountries: v.AllowedCountries}
|
||||
}
|
||||
}
|
||||
r.GeoRestriction = gr
|
||||
}
|
||||
// Geo-restriction status — ALWAYS present (even when never configured) so the hub
|
||||
// always renders the section. A nil pointer (omitempty) made the hub hide the whole
|
||||
// section for a never-configured controller; a present-but-disabled report renders
|
||||
// "Inaktív" hub-side.
|
||||
r.GeoRestriction = buildGeoRestrictionReport(geoRestriction)
|
||||
|
||||
if debug && logger != nil {
|
||||
logger.Printf("[DEBUG] [report] BuildReport: complete — containers=%d, health=%s, deployed=%d, available=%d, app_telemetry=%d",
|
||||
@@ -180,6 +169,29 @@ func BuildReport(
|
||||
return r
|
||||
}
|
||||
|
||||
// buildGeoRestrictionReport always returns a non-nil report so the hub always renders the
|
||||
// geo-restriction section. A nil or never-configured input yields Enabled=false with an
|
||||
// empty (non-nil) country list — the hub renders that as "Inaktív".
|
||||
func buildGeoRestrictionReport(geo *settings.GeoRestriction) *GeoRestrictionReport {
|
||||
gr := &GeoRestrictionReport{Enabled: false, AllowedCountries: []string{}}
|
||||
if geo == nil {
|
||||
return gr
|
||||
}
|
||||
gr.Enabled = geo.Enabled
|
||||
if geo.AllowedCountries != nil {
|
||||
gr.AllowedCountries = geo.AllowedCountries
|
||||
}
|
||||
gr.LastSync = geo.LastSync
|
||||
gr.LastSyncError = geo.LastSyncError
|
||||
if len(geo.AppOverrides) > 0 {
|
||||
gr.AppOverrides = make(map[string]GeoAppOverrideReport, len(geo.AppOverrides))
|
||||
for k, v := range geo.AppOverrides {
|
||||
gr.AppOverrides[k] = GeoAppOverrideReport{AllowedCountries: v.AllowedCountries}
|
||||
}
|
||||
}
|
||||
return gr
|
||||
}
|
||||
|
||||
func buildContainerReport(stackMgr *stacks.Manager, metricsStore *metrics.MetricsStore) ContainerReport {
|
||||
cr := ContainerReport{}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// buildGeoRestrictionReport must ALWAYS return a non-nil, present-but-disabled report for
|
||||
// a nil input. The old inline code left Report.GeoRestriction nil (omitempty), which made
|
||||
// the hub hide the whole geo section for a never-configured controller — this test fails
|
||||
// against that old behaviour (companion).
|
||||
func TestBuildGeoRestrictionReport_NilIsPresentAndDisabled(t *testing.T) {
|
||||
gr := buildGeoRestrictionReport(nil)
|
||||
if gr == nil {
|
||||
t.Fatal("geo report is nil for nil input — the hub would omit the section")
|
||||
}
|
||||
if gr.Enabled {
|
||||
t.Fatalf("Enabled = true, want false for nil input")
|
||||
}
|
||||
if gr.AllowedCountries == nil {
|
||||
t.Fatalf("AllowedCountries is nil, want empty non-nil slice (renders as [], not null)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGeoRestrictionReport_PopulatedPassesThrough(t *testing.T) {
|
||||
in := &settings.GeoRestriction{Enabled: true, AllowedCountries: []string{"HU", "DE"}}
|
||||
gr := buildGeoRestrictionReport(in)
|
||||
if gr == nil || !gr.Enabled || len(gr.AllowedCountries) != 2 {
|
||||
t.Fatalf("passthrough failed: %+v", gr)
|
||||
}
|
||||
}
|
||||
|
||||
// A disabled config with a nil country list must still yield an empty (non-nil) list.
|
||||
func TestBuildGeoRestrictionReport_DisabledNilCountries(t *testing.T) {
|
||||
gr := buildGeoRestrictionReport(&settings.GeoRestriction{Enabled: false})
|
||||
if gr == nil || gr.Enabled || gr.AllowedCountries == nil {
|
||||
t.Fatalf("disabled report wrong: %+v", gr)
|
||||
}
|
||||
}
|
||||
@@ -734,7 +734,10 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
|
||||
count++;
|
||||
}
|
||||
list.innerHTML = html || '<div class="geo-country-option" style="opacity:.5">Nincs találat</div>';
|
||||
list.style.display = count > 0 || q ? '' : 'none';
|
||||
// Reveal with 'block', NOT '' — the .geo-country-list CSS default is display:none,
|
||||
// and clearing the inline style ('') would fall back to that and keep the (populated)
|
||||
// list hidden. This was the country-autocomplete "no list" bug.
|
||||
list.style.display = count > 0 || q ? 'block' : 'none';
|
||||
};
|
||||
|
||||
window.addCountry = function(code, name) {
|
||||
@@ -1112,9 +1115,42 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Controller restart (self-serve) -->
|
||||
<div class="settings-card">
|
||||
<h3>Vezérlő újraindítása</h3>
|
||||
<p class="settings-card-desc">
|
||||
Ha a vezérlő hibásan működik, itt biztonságosan újraindíthatja — nem kell az egész szervert újraindítani.
|
||||
Az alkalmazásai futnak tovább; csak a vezérlő indul újra (néhány másodperc).
|
||||
</p>
|
||||
<div id="restart-status"></div>
|
||||
<button type="button" class="btn btn-outline" id="btn-restart-controller" onclick="restartController()">Vezérlő újraindítása</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
function restartController() {
|
||||
if (!confirm('Biztosan újraindítja a vezérlőt? A művelet néhány másodpercig tart, és a felület rövid időre elérhetetlen lesz.')) return;
|
||||
var btn = document.getElementById('btn-restart-controller');
|
||||
var status = document.getElementById('restart-status');
|
||||
btn.disabled = true;
|
||||
status.innerHTML = '<div class="alert alert-info">Újraindítás folyamatban… újracsatlakozás…</div>';
|
||||
fetch('/api/selfrestart', { method: 'POST', headers: csrfHeaders() })
|
||||
.then(function(){ pollRestart(0); })
|
||||
.catch(function(){ pollRestart(0); }); // connection may drop as the process exits — poll regardless
|
||||
}
|
||||
function pollRestart(attempt) {
|
||||
if (attempt > 60) { // ~2 min cap — never leave the user on a dead page silently
|
||||
document.getElementById('restart-status').innerHTML =
|
||||
'<div class="alert alert-error">Az újraindítás a vártnál tovább tart. Töltse újra az oldalt kézzel.</div>';
|
||||
return;
|
||||
}
|
||||
setTimeout(function(){
|
||||
fetch('/', { method: 'GET', cache: 'no-store' })
|
||||
.then(function(r){ if (r.ok) { window.location.reload(); } else { pollRestart(attempt + 1); } })
|
||||
.catch(function(){ pollRestart(attempt + 1); });
|
||||
}, 2000);
|
||||
}
|
||||
function editStorageLabel(path, currentLabel) {
|
||||
var wrap = document.getElementById('label-wrap-' + path);
|
||||
if (!wrap) return;
|
||||
|
||||
Reference in New Issue
Block a user