mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user