From 423e78aba94f6ccd01584e404791b4dfc1768b6d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 1 Jun 2026 14:32:29 +0200 Subject: [PATCH] webui: clear stale cfg-unsaved banner across device reboots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Running config has unsaved changes" banner kept showing after a power cycle even though the device reloaded running from startup at boot. The cookie was just "1" with a 24-hour MaxAge, so the browser held on to it and the banner stayed up — but the state it referred to was long gone. Carry a unix timestamp in the cookie value (when set) and compare against a webuiStartTime captured at process start. Cookies whose timestamp predates this run are from a previous boot or webui restart; running was reloaded from startup since then, so they no longer describe reality and the banner must stay hidden. - Apply / Restore-to-running: cookie = now → banner shown. - Power cycle, page load: webuiStartTime > cookie ts → banner hidden. - Save / Apply-and-Save: cookie cleared (unchanged). - Legacy "1" / unparseable values: treated as stale. Also centre the "Device unreachable" connectivity banner past the sidebar on desktop — left:0 leaves its text drifting behind the sidebar's z-index. On ≤1024px the sidebar slides off-screen so the banner reclaims full width. Signed-off-by: Joachim Wiberg --- .../internal/handlers/cfg_unsaved_test.go | 43 +++++++++++++++++++ src/webui/internal/handlers/common.go | 9 +++- src/webui/internal/handlers/configure.go | 40 ++++++++++++++++- src/webui/internal/handlers/dashboard.go | 7 ++- src/webui/internal/handlers/yang_tree.go | 4 +- src/webui/static/css/style.css | 10 ++++- src/webui/templates/layouts/base.html | 1 + src/webui/templates/pages/firmware.html | 2 - 8 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 src/webui/internal/handlers/cfg_unsaved_test.go diff --git a/src/webui/internal/handlers/cfg_unsaved_test.go b/src/webui/internal/handlers/cfg_unsaved_test.go new file mode 100644 index 00000000..0bcd8895 --- /dev/null +++ b/src/webui/internal/handlers/cfg_unsaved_test.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" +) + +func TestCfgUnsavedFromRequest(t *testing.T) { + orig := webuiStartTime + defer func() { webuiStartTime = orig }() + webuiStartTime = time.Now() + + mkReq := func(value string) *http.Request { + r := httptest.NewRequest("GET", "/", nil) + if value != "" { + r.AddCookie(&http.Cookie{Name: cfgUnsavedCookie, Value: value}) + } + return r + } + + cases := []struct { + name string + value string + want bool + }{ + {"no cookie", "", false}, + {"empty value", "", false}, + {"legacy literal 1", "1", false}, + {"junk", "abc", false}, + {"before boot", strconv.FormatInt(webuiStartTime.Add(-time.Hour).Unix(), 10), false}, + {"after boot", strconv.FormatInt(webuiStartTime.Add(time.Hour).Unix(), 10), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := cfgUnsavedFromRequest(mkReq(tc.value)); got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go index 3af4aa25..37f85775 100644 --- a/src/webui/internal/handlers/common.go +++ b/src/webui/internal/handlers/common.go @@ -18,6 +18,12 @@ type PageData struct { ActivePage string Capabilities *Capabilities CfgUnsaved bool // running config differs from startup (Apply was used without ApplyAndSave) + // RetryAfter, when > 0, renders a + // in the page head with that many seconds. Used by transient + // fetch failures (e.g. post-upgrade dashboard fetch racing + // yanger / sysrepo startup) to self-recover without the user + // having to remember to reload. + RetryAfter int } func csrfToken(ctx context.Context) string { @@ -25,13 +31,12 @@ func csrfToken(ctx context.Context) string { } func newPageData(r *http.Request, page, title string) PageData { - _, cookieErr := r.Cookie(cfgUnsavedCookie) return PageData{ Username: restconf.CredentialsFromContext(r.Context()).Username, CsrfToken: csrfToken(r.Context()), PageTitle: title, ActivePage: page, Capabilities: CapabilitiesFromContext(r.Context()), - CfgUnsaved: cookieErr == nil, + CfgUnsaved: cfgUnsavedFromRequest(r), } } diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go index 0d83e8aa..4a551d22 100644 --- a/src/webui/internal/handlers/configure.go +++ b/src/webui/internal/handlers/configure.go @@ -5,25 +5,63 @@ package handlers import ( "log" "net/http" + "strconv" + "time" "github.com/kernelkit/webui/internal/restconf" ) const cfgUnsavedCookie = "cfg-unsaved" +// webuiStartTime is captured when the process starts. The cfg-unsaved +// cookie carries the timestamp it was set at; cookies whose timestamp +// predates this value are from a previous boot (or a previous webui +// restart) and the running/startup state they referred to no longer +// applies — running was reloaded from startup at boot, so they're +// equal and the banner must not show. Using process-start instead of +// kernel boot keeps the check entirely in-process: a webui restart +// from configd flush also resets the marker, which matches "fresh +// running" semantics. +var webuiStartTime = time.Now() + // ConfigureHandler manages the candidate datastore lifecycle. type ConfigureHandler struct { RC restconf.Fetcher } func setCfgUnsaved(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{Name: cfgUnsavedCookie, Value: "1", Path: "/", MaxAge: 86400, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{ + Name: cfgUnsavedCookie, + Value: strconv.FormatInt(time.Now().Unix(), 10), + Path: "/", + MaxAge: 86400, + SameSite: http.SameSiteLaxMode, + }) } func clearCfgUnsaved(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: cfgUnsavedCookie, Value: "", Path: "/", MaxAge: -1, SameSite: http.SameSiteLaxMode}) } +// cfgUnsavedFromRequest returns true when the cfg-unsaved cookie is +// present AND its timestamp is from this webui session. Stale cookies +// (process restart / device reboot since the cookie was set) are +// treated as absent — running has been reloaded from startup, so the +// "unsaved" condition the cookie referred to no longer holds. +func cfgUnsavedFromRequest(r *http.Request) bool { + cookie, err := r.Cookie(cfgUnsavedCookie) + if err != nil { + return false + } + ts, perr := strconv.ParseInt(cookie.Value, 10, 64) + if perr != nil { + // Legacy cookie format ("1") from before the timestamp change, + // or unparseable junk. Treat as stale. + return false + } + return time.Unix(ts, 0).After(webuiStartTime) +} + // Enter copies running → candidate, initialising a fresh edit session. // Called when the user opens the Configure accordion. // POST /configure/enter diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index a8876e7e..0a46308e 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -298,7 +298,12 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { if stateErr != nil { log.Printf("restconf system-state: %v", stateErr) - data.Error = "Could not fetch system information" + data.Error = "Could not fetch system information — retrying…" + // Post-upgrade / fresh-boot race: yanger or sysrepo not ready + // yet. Schedule a page-level meta-refresh so the dashboard + // self-recovers instead of stranding the user on a stale + // error banner. + data.RetryAfter = 5 } else { ss := state.SystemState data.OSName = ss.Platform.OSName diff --git a/src/webui/internal/handlers/yang_tree.go b/src/webui/internal/handlers/yang_tree.go index f76041f7..9133ca13 100644 --- a/src/webui/internal/handlers/yang_tree.go +++ b/src/webui/internal/handlers/yang_tree.go @@ -1285,7 +1285,9 @@ func (h *TreeHandler) DeleteContainer(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadGateway) return } - setCfgUnsaved(w) + // No setCfgUnsaved here — this only modifies candidate. The cfg- + // unsaved banner is specifically about "running differs from + // startup," set after Apply / RestoreConfig-to-running. parentSchema, err := mgr.NodeAt(stripKeyPredicate(parent)) if err != nil || parentSchema == nil { diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 79fd05b4..c76cd948 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -757,10 +757,18 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { font-weight: 600; position: fixed; top: var(--topbar-height); - left: 0; + /* #sidebar sits at z-index 150 (above this banner's 100), so a + left:0 banner gets its left edge clipped behind the sidebar and + the text drifts visually off-centre. Inset the banner past the + sidebar on desktop; on ≤1024px the sidebar slides off-screen + and the banner reclaims full width. */ + left: var(--sidebar-width); right: 0; z-index: 100; } +@media (max-width: 1024px) { + .conn-banner { left: 0; } +} .alert { padding: 1rem; diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index 48df5619..a12ad2cc 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -7,6 +7,7 @@ + {{if .RetryAfter}}{{end}} {{if .PageTitle}}{{.PageTitle}} — {{end}}Management diff --git a/src/webui/templates/pages/firmware.html b/src/webui/templates/pages/firmware.html index b10139aa..14e87875 100644 --- a/src/webui/templates/pages/firmware.html +++ b/src/webui/templates/pages/firmware.html @@ -50,8 +50,6 @@ {{end}} - {{else if .Installing}} -

Slot details paused during install.

{{else}}

No software information available.

{{end}}