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