From b0bd1dad110b9a33ce811e08c19fdac3bbeeb9a6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 27 Apr 2026 18:37:22 +0200 Subject: [PATCH] =?UTF-8?q?webui:=20configure=20routes=20=E2=80=94=20stati?= =?UTF-8?q?c=20route=20management=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Configure > Routes page for viewing, adding, editing, and deleting IPv4 and IPv6 static routes. Each route row expands in-place to an edit form; YANG field descriptions are shown as ⓘ tooltips. Also fixes a goyang bug where inline augments inside a uses block were never applied to the expanded tree, making it so next-hop-address and outgoing-interface were missing from the schema — and therefore had no tooltip text. Signed-off-by: Joachim Wiberg --- src/webui/internal/goyang/pkg/yang/entry.go | 10 + .../internal/handlers/configure_routes.go | 314 ++++++++++++++++++ src/webui/internal/restconf/client.go | 9 + src/webui/internal/server/server.go | 10 +- src/webui/static/css/style.css | 2 +- src/webui/static/img/icons/routes.svg | 7 + src/webui/templates/layouts/sidebar.html | 12 +- .../templates/pages/configure-routes.html | 235 +++++++++++++ 8 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 src/webui/internal/handlers/configure_routes.go create mode 100644 src/webui/static/img/icons/routes.svg create mode 100644 src/webui/templates/pages/configure-routes.html diff --git a/src/webui/internal/goyang/pkg/yang/entry.go b/src/webui/internal/goyang/pkg/yang/entry.go index fa50af1b..cbba30b7 100644 --- a/src/webui/internal/goyang/pkg/yang/entry.go +++ b/src/webui/internal/goyang/pkg/yang/entry.go @@ -878,6 +878,16 @@ func ToEntry(n Node) (e *Entry) { for _, a := range fv.Interface().([]*Uses) { grouping := ToEntry(a) e.merge(nil, nil, grouping) + // Apply inline augments from the uses statement. Their paths + // are relative to e (the node where the uses appears), so we + // resolve them directly rather than deferring to e.Augment(). + for _, aug := range a.Augment { + target := e.Find(aug.Name) + if target != nil { + augEntry := ToEntry(aug) + target.merge(nil, augEntry.Namespace(), augEntry) + } + } if ms.ParseOptions.StoreUses { e.Uses = append(e.Uses, &UsesStmt{a, grouping.shallowDup()}) } diff --git a/src/webui/internal/handlers/configure_routes.go b/src/webui/internal/handlers/configure_routes.go new file mode 100644 index 00000000..0be8b02c --- /dev/null +++ b/src/webui/internal/handlers/configure_routes.go @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "errors" + "fmt" + "html/template" + "log" + "net/http" + "strings" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/schema" +) + +// ─── RESTCONF paths ────────────────────────────────────────────────────────── + +const ( + staticCPPSuffix = "/ietf-routing:routing/control-plane-protocols/control-plane-protocol=infix-routing%3Astatic,static" + staticRtSuffix = staticCPPSuffix + "/static-routes" + staticIPv4Suffix = staticRtSuffix + "/ietf-ipv4-unicast-routing:ipv4" + staticIPv6Suffix = staticRtSuffix + "/ietf-ipv6-unicast-routing:ipv6" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +type staticCPPWrapper struct { + Routing struct { + CPPs struct { + CPP []staticCPPJSON `json:"control-plane-protocol"` + } `json:"control-plane-protocols"` + } `json:"ietf-routing:routing"` +} + +// staticCPPJSON mirrors control-plane-protocol. The ipv4/ipv6 containers are +// augmented into the child static-routes container, not directly into the CPP. +type staticCPPJSON struct { + Type string `json:"type"` + Name string `json:"name"` + StaticRoutes staticRoutesJSON `json:"static-routes"` +} + +type staticRoutesJSON struct { + IPv4 *staticIPJSON `json:"ietf-ipv4-unicast-routing:ipv4,omitempty"` + IPv6 *staticIPJSON `json:"ietf-ipv6-unicast-routing:ipv6,omitempty"` +} + +type staticIPJSON struct { + Routes []staticRouteJSON `json:"route"` +} + +type staticRouteJSON struct { + Prefix string `json:"destination-prefix"` + NextHop staticNextHopJSON `json:"next-hop"` +} + +type staticNextHopJSON struct { + Address string `json:"next-hop-address,omitempty"` + Interface string `json:"outgoing-interface,omitempty"` +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type cfgRouteEntry struct { + Prefix string + NextHop string + Interface string +} + +type cfgRoutesPageData struct { + PageData + Loading bool + IPv4Routes []cfgRouteEntry + IPv6Routes []cfgRouteEntry + Desc map[string]string + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureRoutesHandler serves the Configure > Routes page. +type ConfigureRoutesHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +// Overview renders the Configure > Routes page reading from the candidate. +// GET /configure/routes +func (h *ConfigureRoutesHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgRoutesPageData{ + PageData: newPageData(r, "configure-routes", "Configure: Routes"), + } + + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + rt := "/ietf-routing:routing/control-plane-protocols/control-plane-protocol/static-routes/ietf-ipv4-unicast-routing:ipv4/route" + data.Desc = map[string]string{ + "prefix": schema.DescriptionOf(mgr, rt+"/destination-prefix"), + "nexthop": schema.DescriptionOf(mgr, rt+"/next-hop/next-hop-address"), + "interface": schema.DescriptionOf(mgr, rt+"/next-hop/outgoing-interface"), + } + } + + cpp, err := h.fetchStaticCPP(r.Context()) + if err != nil { + log.Printf("configure routes: %v", err) + data.Error = "Could not read static routes" + } + if len(cpp.Routing.CPPs.CPP) > 0 { + entry := cpp.Routing.CPPs.CPP[0] + if entry.StaticRoutes.IPv4 != nil { + for _, rt := range entry.StaticRoutes.IPv4.Routes { + data.IPv4Routes = append(data.IPv4Routes, cfgRouteEntry{ + Prefix: rt.Prefix, + NextHop: rt.NextHop.Address, + Interface: rt.NextHop.Interface, + }) + } + } + if entry.StaticRoutes.IPv6 != nil { + for _, rt := range entry.StaticRoutes.IPv6.Routes { + data.IPv6Routes = append(data.IPv6Routes, cfgRouteEntry{ + Prefix: rt.Prefix, + NextHop: rt.NextHop.Address, + Interface: rt.NextHop.Interface, + }) + } + } + } + + tmplName := "configure-routes.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// AddRoute adds a static route to the candidate datastore. +// POST /configure/routes +func (h *ConfigureRoutesHandler) AddRoute(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + prefix := strings.TrimSpace(r.FormValue("prefix")) + nexthop := strings.TrimSpace(r.FormValue("nexthop")) + iface := strings.TrimSpace(r.FormValue("interface")) + + ipKey, _, ok := familyKeys(r.FormValue("family")) + if !ok { + renderSaveError(w, fmt.Errorf("invalid address family")) + return + } + if prefix == "" { + renderSaveError(w, fmt.Errorf("destination prefix is required")) + return + } + if nexthop == "" && iface == "" { + renderSaveError(w, fmt.Errorf("next-hop address or outgoing interface is required")) + return + } + + nhMap := map[string]any{} + if nexthop != "" { + nhMap["next-hop-address"] = nexthop + } + if iface != "" { + nhMap["outgoing-interface"] = iface + } + + // PATCH at the routing root so the CPP is created if absent (merge semantics). + // ipv4/ipv6 containers live under the static-routes intermediate container. + body := map[string]any{ + "ietf-routing:routing": map[string]any{ + "control-plane-protocols": map[string]any{ + "control-plane-protocol": []map[string]any{{ + "type": "infix-routing:static", + "name": "static", + "static-routes": map[string]any{ + ipKey: map[string]any{ + "route": []map[string]any{{ + "destination-prefix": prefix, + "next-hop": nhMap, + }}, + }, + }, + }}, + }, + }, + } + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-routing:routing", body); err != nil { + log.Printf("configure routes add %q: %v", prefix, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Route added", "/configure/routes") +} + +// UpdateRoute replaces the next-hop of an existing static route. +// PUT /configure/routes +func (h *ConfigureRoutesHandler) UpdateRoute(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + prefix := strings.TrimSpace(r.FormValue("prefix")) + nexthop := strings.TrimSpace(r.FormValue("nexthop")) + iface := strings.TrimSpace(r.FormValue("interface")) + + _, suffix, ok := familyKeys(r.FormValue("family")) + if !ok || prefix == "" { + renderSaveError(w, fmt.Errorf("invalid address family or prefix")) + return + } + if nexthop == "" && iface == "" { + renderSaveError(w, fmt.Errorf("next-hop address or outgoing interface is required")) + return + } + + nhMap := map[string]any{} + if nexthop != "" { + nhMap["next-hop-address"] = nexthop + } + if iface != "" { + nhMap["outgoing-interface"] = iface + } + + var routeKey string + switch r.FormValue("family") { + case "ipv4": + routeKey = "ietf-ipv4-unicast-routing:route" + case "ipv6": + routeKey = "ietf-ipv6-unicast-routing:route" + } + + path := candidatePath + suffix + "/route=" + restconf.EscapeKey(prefix) + body := map[string]any{ + routeKey: []map[string]any{{ + "destination-prefix": prefix, + "next-hop": nhMap, + }}, + } + if err := h.RC.Put(r.Context(), path, body); err != nil { + log.Printf("configure routes update %q: %v", prefix, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Route updated", "/configure/routes") +} + +// DeleteRoute removes a static route from the candidate datastore. +// DELETE /configure/routes?family=ipv4&prefix=10.0.0.0/24 +func (h *ConfigureRoutesHandler) DeleteRoute(w http.ResponseWriter, r *http.Request) { + prefix := r.URL.Query().Get("prefix") + _, suffix, ok := familyKeys(r.URL.Query().Get("family")) + if !ok || prefix == "" { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + path := candidatePath + suffix + "/route=" + restconf.EscapeKey(prefix) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure routes delete %q: %v", prefix, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Route deleted", "/configure/routes") +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// familyKeys maps an address family string ("ipv4" or "ipv6") to the +// YANG module-qualified ipv4/ipv6 key and the RESTCONF path suffix for that +// family's route list. ok is false for unknown family values. +func familyKeys(family string) (ipKey, suffix string, ok bool) { + switch family { + case "ipv4": + return "ietf-ipv4-unicast-routing:ipv4", staticIPv4Suffix, true + case "ipv6": + return "ietf-ipv6-unicast-routing:ipv6", staticIPv6Suffix, true + } + return "", "", false +} + +// fetchStaticCPP reads the static control-plane-protocol entry from the +// candidate datastore, falling back to running on 404. +func (h *ConfigureRoutesHandler) fetchStaticCPP(ctx context.Context) (staticCPPWrapper, error) { + var cpp staticCPPWrapper + err := h.RC.Get(ctx, candidatePath+staticCPPSuffix, &cpp) + if err == nil { + return cpp, nil + } + var rcErr *restconf.Error + if errors.As(err, &rcErr) && rcErr.StatusCode == http.StatusNotFound { + runErr := h.RC.Get(ctx, "/data"+staticCPPSuffix, &cpp) + if runErr == nil { + return cpp, nil + } + var rcRun *restconf.Error + if errors.As(runErr, &rcRun) && rcRun.StatusCode == http.StatusNotFound { + return cpp, nil // no static routes configured — not an error + } + return cpp, runErr + } + return cpp, err +} diff --git a/src/webui/internal/restconf/client.go b/src/webui/internal/restconf/client.go index 40ff4577..0f647090 100644 --- a/src/webui/internal/restconf/client.go +++ b/src/webui/internal/restconf/client.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -287,3 +288,11 @@ func escapeZoneID(rawURL string) string { } return rawURL } + +// EscapeKey percent-encodes a RESTCONF list-key value for use in a URL path. +// url.PathEscape leaves ':' unescaped, but rousette parses ':' in a path +// segment as the module:node separator — so an IPv6 key like 2001:db8::1 must +// have its colons encoded or the request fails with a URI "Syntax error". +func EscapeKey(s string) string { + return strings.ReplaceAll(url.PathEscape(s), ":", "%3A") +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 90eee648..3606e827 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -112,6 +112,10 @@ func New( if err != nil { return nil, err } + cfgRoutesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-routes.html") + if err != nil { + return nil, err + } yangTreeTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", @@ -129,7 +133,6 @@ func New( if err != nil { return nil, err } - login := &auth.LoginHandler{ Store: store, RC: rc, @@ -182,6 +185,7 @@ func New( cfg := &handlers.ConfigureHandler{RC: rc} cfgSys := &handlers.ConfigureSystemHandler{Template: cfgSysTmpl, RC: rc, Schema: schemaCache} cfgUsers := &handlers.ConfigureUsersHandler{Template: cfgUsersTmpl, RC: rc, Schema: schemaCache} + cfgRoutes := &handlers.ConfigureRoutesHandler{Template: cfgRoutesTmpl, RC: rc, Schema: schemaCache} schemaH := &handlers.SchemaHandler{Cache: schemaCache} dataH := &handlers.DataHandler{RC: rc, Schema: schemaCache} treeH := &handlers.TreeHandler{ @@ -249,6 +253,10 @@ func New( mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP) mux.HandleFunc("PUT /configure/system/dns", cfgSys.SaveDNS) mux.HandleFunc("POST /configure/system/preferences", cfgSys.SavePreferences) + mux.HandleFunc("GET /configure/routes", cfgRoutes.Overview) + mux.HandleFunc("POST /configure/routes", cfgRoutes.AddRoute) + mux.HandleFunc("PUT /configure/routes", cfgRoutes.UpdateRoute) + mux.HandleFunc("DELETE /configure/routes", cfgRoutes.DeleteRoute) mux.HandleFunc("GET /configure/users", cfgUsers.Overview) mux.HandleFunc("POST /configure/users", cfgUsers.AddUser) mux.HandleFunc("DELETE /configure/users/{name}", cfgUsers.DeleteUser) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 8a55ed3d..dae929d3 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2122,7 +2122,7 @@ select.cfg-input { /* Editable table rows */ .cfg-table td { padding: 0.45rem 0.75rem; vertical-align: middle; } -.cfg-table td:last-child { width: 3rem; text-align: center; } +.cfg-table > tbody > tr > td:last-child { width: 3rem; text-align: center; } /* Leading nav-chevron column for complex (drill-down) list rows */ .yt-nav-col { width: 1.5rem; padding: 0.25rem !important; text-align: center; color: var(--fg-muted); } .btn-icon { diff --git a/src/webui/static/img/icons/routes.svg b/src/webui/static/img/icons/routes.svg new file mode 100644 index 00000000..158e36c5 --- /dev/null +++ b/src/webui/static/img/icons/routes.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 3186e4c1..547bc9d4 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -147,7 +147,7 @@