webui: configure routes — static route management page

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 <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:14 +02:00
parent 6e30d4f590
commit b0bd1dad11
8 changed files with 596 additions and 3 deletions
@@ -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()})
}
@@ -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
}
+9
View File
@@ -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")
}
+9 -1
View File
@@ -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)
+1 -1
View File
@@ -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 {
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="5" cy="12" r="2"/>
<circle cx="19" cy="6" r="2"/>
<circle cx="19" cy="18" r="2"/>
<line x1="7" y1="11" x2="17" y2="7"/>
<line x1="7" y1="13" x2="17" y2="17"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

+11 -1
View File
@@ -147,7 +147,7 @@
</details>
<details class="nav-group-top" id="nav-configure"
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-users") (eq .ActivePage "configure-keystore") (eq .ActivePage "configure-tree")}}open{{end}}>
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-routes") (eq .ActivePage "configure-users") (eq .ActivePage "configure-keystore") (eq .ActivePage "configure-tree")}}open{{end}}>
<summary class="nav-group-summary-top">Configure</summary>
<ul class="nav-group-items">
<li>
@@ -160,6 +160,16 @@
System
</a>
</li>
<li>
<a href="/configure/routes"
hx-get="/configure/routes"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "configure-routes"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/routes.svg')"></span>
Routes
</a>
</li>
<li>
<a href="/configure/users"
hx-get="/configure/users"
@@ -0,0 +1,235 @@
{{define "configure-routes.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<div class="configure-page">
{{if .Loading}}
<div class="alert alert-info"
hx-get="/configure/routes"
hx-trigger="every 3s"
hx-target="#content"
hx-swap="innerHTML">
Downloading YANG schema from device&hellip; this takes a moment on first login.
</div>
{{template "configure-toolbar" .}}
</div>
{{else}}
{{$d := .Desc}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<section class="info-grid">
<section class="info-card info-grid-span-2">
<div class="card-header">IPv4 Static Routes</div>
<div class="data-table-wrap">
<table class="data-table cfg-table" id="ipv4-routes-table">
<thead>
<tr>
<th>Destination{{template "field-info" (index $d "prefix")}}</th>
<th>Next Hop{{template "field-info" (index $d "nexthop")}}</th>
<th>Interface{{template "field-info" (index $d "interface")}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $rt := .IPv4Routes}}
<tr>
<td class="num">
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="ipv4-route-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Prefix}}
</button>
</td>
<td>{{.NextHop}}</td>
<td>{{.Interface}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/routes?family=ipv4&prefix={{.Prefix | urlquery}}"
hx-confirm="Delete route {{.Prefix}}?"
hx-swap="none"
title="Delete route">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6M14 11v6"></path>
</svg>
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-ipv4-route-{{$i}}">
<td colspan="4" class="key-detail-cell">
<div class="key-detail-body">
<form hx-put="/configure/routes" hx-swap="none">
<input type="hidden" name="family" value="ipv4">
<input type="hidden" name="prefix" value="{{.Prefix}}">
<table class="info-table">
<tr>
<th>Destination</th>
<td><span class="text-muted">{{.Prefix}}</span></td>
</tr>
<tr>
<th>Next hop{{template "field-info" (index $d "nexthop")}}</th>
<td><input class="cfg-input" type="text" name="nexthop"
value="{{.NextHop}}" placeholder="e.g. 192.168.1.1"></td>
</tr>
<tr>
<th>Interface{{template "field-info" (index $d "interface")}}</th>
<td><input class="cfg-input" type="text" name="interface"
value="{{.Interface}}" placeholder="e.g. eth0"></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</td>
</tr>
{{end}}
{{if not .IPv4Routes}}
<tr><td colspan="4" class="yt-table-empty">No IPv4 static routes</td></tr>
{{end}}
{{/* Hidden add row */}}
<tr id="ipv4-add-row" hidden>
<td colspan="4" class="key-detail-cell">
<form hx-post="/configure/routes" hx-swap="none" class="user-add-inline">
<input type="hidden" name="family" value="ipv4">
<input class="cfg-input" type="text" name="prefix" required
placeholder="0.0.0.0/0">
<input class="cfg-input" type="text" name="nexthop"
placeholder="Next hop (e.g. 192.168.1.1)">
<input class="cfg-input" type="text" name="interface"
placeholder="Interface (e.g. eth0)">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm"
data-hide="ipv4-add-row">Cancel</button>
<span class="cfg-save-status"></span>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button class="btn btn-secondary" data-show="ipv4-add-row">+ Add Route</button>
</div>
</section>
<section class="info-card info-grid-span-2">
<div class="card-header">IPv6 Static Routes</div>
<div class="data-table-wrap">
<table class="data-table cfg-table" id="ipv6-routes-table">
<thead>
<tr>
<th>Destination{{template "field-info" (index $d "prefix")}}</th>
<th>Next Hop{{template "field-info" (index $d "nexthop")}}</th>
<th>Interface{{template "field-info" (index $d "interface")}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $rt := .IPv6Routes}}
<tr>
<td class="num">
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="ipv6-route-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Prefix}}
</button>
</td>
<td>{{.NextHop}}</td>
<td>{{.Interface}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/routes?family=ipv6&prefix={{.Prefix | urlquery}}"
hx-confirm="Delete route {{.Prefix}}?"
hx-swap="none"
title="Delete route">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6M14 11v6"></path>
</svg>
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-ipv6-route-{{$i}}">
<td colspan="4" class="key-detail-cell">
<div class="key-detail-body">
<form hx-put="/configure/routes" hx-swap="none">
<input type="hidden" name="family" value="ipv6">
<input type="hidden" name="prefix" value="{{.Prefix}}">
<table class="info-table">
<tr>
<th>Destination</th>
<td><span class="text-muted">{{.Prefix}}</span></td>
</tr>
<tr>
<th>Next hop{{template "field-info" (index $d "nexthop")}}</th>
<td><input class="cfg-input" type="text" name="nexthop"
value="{{.NextHop}}" placeholder="e.g. 2001:db8::1"></td>
</tr>
<tr>
<th>Interface{{template "field-info" (index $d "interface")}}</th>
<td><input class="cfg-input" type="text" name="interface"
value="{{.Interface}}" placeholder="e.g. eth0"></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</td>
</tr>
{{end}}
{{if not .IPv6Routes}}
<tr><td colspan="4" class="yt-table-empty">No IPv6 static routes</td></tr>
{{end}}
{{/* Hidden add row */}}
<tr id="ipv6-add-row" hidden>
<td colspan="4" class="key-detail-cell">
<form hx-post="/configure/routes" hx-swap="none" class="user-add-inline">
<input type="hidden" name="family" value="ipv6">
<input class="cfg-input" type="text" name="prefix" required
placeholder="::/0">
<input class="cfg-input" type="text" name="nexthop"
placeholder="Next hop (e.g. 2001:db8::1)">
<input class="cfg-input" type="text" name="interface"
placeholder="Interface (e.g. eth0)">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm"
data-hide="ipv6-add-row">Cancel</button>
<span class="cfg-save-status"></span>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button class="btn btn-secondary" data-show="ipv6-add-row">+ Add Route</button>
</div>
</section>
</section>
{{template "configure-toolbar" .}}
</div>
{{end}}{{/* end {{else}} — schema ready */}}
{{end}}
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}"></span>{{end}}{{end}}