Compare commits

..

4 Commits

Author SHA1 Message Date
admin c514c0042b docs: CHANGELOG + README for v0.70.0 (config-apply self-restart, restart button, geo report push, always-report geo, autocomplete fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:03:08 +02:00
admin 09d75c1e2e controller: fix country autocomplete — reveal dropdown with display:block (C)
Root cause (diagnosed live, not the hypothesised JS throw): filterCountries runs
fine and correctly populates the list, but reveals it with `list.style.display=''`.
The .geo-country-list CSS default is `display:none` (style.css), so clearing the
inline style falls back to none and the populated list never shows — no console
error, just an invisible dropdown. Latent since the geo feature's first commit.
Fix: reveal with 'block'. Verified live (typing "Német" now lists Németország).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:01:11 +02:00
admin 02a4ba0491 controller: immediate hub report push on geo change + always-report geo (B)
- Geo settings save and manual geo sync now fire an out-of-band, non-blocking hub
  report push (Router.reportPushNow seam, 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. Scope: geo handlers only.
- builder.go always populates report.GeoRestriction (Enabled=false, empty countries
  when nil/disabled) via new buildGeoRestrictionReport helper, so the hub always
  renders the geo section ("Inaktív" when off) instead of hiding it via omitempty.
- Tests: geo save success → push once; invalid country → no push (companion);
  buildGeoRestrictionReport(nil) → non-nil disabled (companion vs old nil-omit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:00:32 +02:00
admin ba87412508 controller: config-apply self-restart + manual restart button (A)
POST /api/config/apply now takes effect via a graceful SELF-RESTART instead of
logging "restart needed" and leaving stale in-process singletons (the CF client
is built once at startup, so a rotated Cloudflare token never applied until a
manual LXC restart). Container is restart:unless-stopped, so a clean os.Exit(0)
auto-restarts with fresh config.

- New gracefulSelfRestart helper behind an injectable Restarter seam (Router.restart
  + SetRestarter) so the exit is unit-testable.
- configApply: no-op guard (byte-identical re-push → no write, no restart), else
  write → 200 (flushed) → restart. Removed stale "restart needed" wording.
- Removed the dead OnConfigApplied hook (Phase-1-retired infra-backup push; the
  self-restart reloads everything and a fresh report is pushed on startup).
- New POST /api/selfrestart (auth+CSRF via /api/ mount) + "Vezérlő újraindítása"
  settings button: confirm → POST → poll GET / every 2s → reload.
- Tests: changed→restart once; identical→not called (companion); invalid→not called;
  selfrestart→restart once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:56:51 +02:00
11 changed files with 416 additions and 31 deletions
+28
View File
@@ -1,5 +1,33 @@
## Changelog ## 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) ### 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 Controller half of the Phase-1 Infra Backup retirement (hub v0.12.0; see
+13 -1
View File
@@ -1215,15 +1215,27 @@ Five sections:
Periodic JSON push (default every 15 min) to the central felhom-hub service: Periodic JSON push (default every 15 min) to the central felhom-hub service:
- System: hostname, OS, CPU, memory, disk usage, uptime - System: hostname, OS, CPU, memory, disk usage, uptime
- Containers: running/stopped counts, per-container CPU/memory - 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 - Health: current status, issues, warnings
- Stacks: deployed apps with versions and states - Stacks: deployed apps with versions and states
- Config hash: SHA256 of `controller.yaml` for Hub-side config comparison - 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. - **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. - **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. 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`) #### App Telemetry (`internal/metrics/telemetry.go`, `internal/metrics/logscanner.go`, `internal/report/telemetry.go`)
Each report push now includes per-app telemetry data: Each report push now includes per-app telemetry data:
+10 -4
View File
@@ -574,10 +574,16 @@ func main() {
// --- Initialize API router --- // --- Initialize API router ---
apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger) apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger)
if hubPusher != nil { if hubPusher != nil {
apiRouter.OnConfigApplied = func() { // Out-of-band, non-blocking hub report push (e.g. after a geo settings change) so
// Infra backup push is now the host agent's responsibility; the controller // the hub reflects the new state immediately instead of after the next ~15-min cycle.
// only refreshes the Hub report after a config apply. 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 { if assetsSyncer != nil {
apiRouter.SetAssetsSyncer(assetsSyncer) apiRouter.SetAssetsSyncer(assetsSyncer)
+8
View File
@@ -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"}) 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() { go func() {
if err := r.geoSync.Sync(context.Background()); err != nil { if err := r.geoSync.Sync(context.Background()); err != nil {
r.logger.Printf("[ERROR] [api] Manual geo sync failed: %v", err) 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"}) writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
+57
View File
@@ -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)
}
}
+49 -9
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -41,8 +42,14 @@ type Router struct {
notifier *notify.Notifier notifier *notify.Notifier
logger *log.Logger logger *log.Logger
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup). // restart triggers a graceful self-restart (config-apply + the manual restart button).
OnConfigApplied func() // 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 is called after deploy/remove to re-sync geo rules.
OnGeoRelevantChange func() 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 { 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 { type apiResponse struct {
@@ -146,6 +171,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/config" && req.Method == http.MethodGet: case path == "/config" && req.Method == http.MethodGet:
r.configContent(w, req) 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) --- // --- Integration routes (must be before hasSuffix-based stack cases) ---
// GET /api/integrations/{provider} — list integrations for a provider // GET /api/integrations/{provider} — list integrations for a provider
@@ -1058,6 +1087,15 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
return 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 // 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 // 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). // 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 return
} }
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes), restart needed to take effect", len(body)) // Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Config applied. Restart controller to apply changes."}) // 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.
// Push updated infra backup so Hub has fresh config data immediately r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
if r.OnConfigApplied != nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
go r.OnConfigApplied() flushResponse(w)
if r.restart != nil {
r.restart()
} }
} }
+47
View File
@@ -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)
}
}
+28 -16
View File
@@ -155,22 +155,11 @@ func BuildReport(
// App telemetry (metrics + log scan) // App telemetry (metrics + log scan)
r.AppTelemetry = buildAppTelemetrySection(stackMgr, metricsStore, logger) r.AppTelemetry = buildAppTelemetrySection(stackMgr, metricsStore, logger)
// Geo-restriction status // Geo-restriction status — ALWAYS present (even when never configured) so the hub
if geoRestriction != nil { // always renders the section. A nil pointer (omitempty) made the hub hide the whole
gr := &GeoRestrictionReport{ // section for a never-configured controller; a present-but-disabled report renders
Enabled: geoRestriction.Enabled, // "Inaktív" hub-side.
AllowedCountries: geoRestriction.AllowedCountries, r.GeoRestriction = buildGeoRestrictionReport(geoRestriction)
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
}
if debug && logger != nil { if debug && logger != nil {
logger.Printf("[DEBUG] [report] BuildReport: complete — containers=%d, health=%s, deployed=%d, available=%d, app_telemetry=%d", 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 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 { func buildContainerReport(stackMgr *stacks.Manager, metricsStore *metrics.MetricsStore) ContainerReport {
cr := 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++; count++;
} }
list.innerHTML = html || '<div class="geo-country-option" style="opacity:.5">Nincs találat</div>'; 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) { window.addCountry = function(code, name) {
@@ -1112,9 +1115,42 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
</div> </div>
</div> </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}} {{end}}
<script> <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) { function editStorageLabel(path, currentLabel) {
var wrap = document.getElementById('label-wrap-' + path); var wrap = document.getElementById('label-wrap-' + path);
if (!wrap) return; if (!wrap) return;