webui: clear stale cfg-unsaved banner across device reboots

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 <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:19 +02:00
parent 9f03e0a34b
commit 423e78aba9
8 changed files with 108 additions and 8 deletions
@@ -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)
}
})
}
}
+7 -2
View File
@@ -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 <meta http-equiv="refresh">
// 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),
}
}
+39 -1
View File
@@ -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
+6 -1
View File
@@ -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
+3 -1
View File
@@ -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 {
+9 -1
View File
@@ -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;
+1
View File
@@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{.CsrfToken}}">
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
{{if .RetryAfter}}<meta http-equiv="refresh" content="{{.RetryAfter}}">{{end}}
<title>{{if .PageTitle}}{{.PageTitle}} — {{end}}Management</title>
<link rel="icon" type="image/png" href="/assets/img/jack.png">
<link rel="stylesheet" href="/assets/css/style.css">
-2
View File
@@ -50,8 +50,6 @@
</div>
{{end}}
</div>
{{else if .Installing}}
<p class="empty-message">Slot details paused during install.</p>
{{else}}
<p class="empty-message">No software information available.</p>
{{end}}