webui: rebuild NTP & DNS client pages around per-item editing

Replace the single bulk-PUT form + client-side row cloning (which broke
when these pages were split out of System settings) with the
server-driven list pattern used by Keystore/Users: each server is a row
keyed by name that expands to edit, with per-row delete and a dedicated
add form.  DNS search domains become add/delete rows.  Server creates
its container on first use, and field-info ⓘ help is sourced from the
YANG descriptions.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:28 +02:00
parent 1c3086be94
commit a78eeee651
4 changed files with 449 additions and 212 deletions
+230 -96
View File
@@ -4,9 +4,11 @@ package handlers
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
@@ -42,9 +44,11 @@ type cfgNTPJSON struct {
}
type cfgNTPServerJSON struct {
Name string `json:"name"`
UDP cfgNTPUDPJSON `json:"udp"`
Prefer bool `json:"prefer"`
Name string `json:"name"`
UDP cfgNTPUDPJSON `json:"udp"`
AssociationType string `json:"association-type,omitempty"`
IBurst bool `json:"iburst,omitempty"`
Prefer bool `json:"prefer,omitempty"`
}
type cfgNTPUDPJSON struct {
@@ -89,14 +93,17 @@ type cfgSystemPageData struct {
type cfgNTPPageData struct {
PageData
Error string
NTP cfgNTPJSON
Error string
NTP cfgNTPJSON
AssocTypes []string // association-type enum: server / pool / peer
Desc map[string]string // leaf name → YANG description (field-info ⓘ)
}
type cfgDNSPageData struct {
PageData
Error string
DNS cfgDNSJSON
Desc map[string]string // leaf name → YANG description (field-info ⓘ)
}
// ─── Handler ─────────────────────────────────────────────────────────────────
@@ -232,6 +239,18 @@ func (h *ConfigureSystemHandler) OverviewNTP(w http.ResponseWriter, r *http.Requ
data := cfgNTPPageData{
PageData: newPageData(w, r, "configure-ntp", "NTP Client"),
}
data.AssocTypes = []string{"server", "pool", "peer"}
if mgr := h.Schema.Manager(); mgr != nil {
srv := "/ietf-system:system/ntp/server"
data.Desc = map[string]string{
"name": schema.DescriptionOf(mgr, srv+"/name"),
"address": schema.DescriptionOf(mgr, srv+"/udp/address"),
"port": schema.DescriptionOf(mgr, srv+"/udp/port"),
"assoc": schema.DescriptionOf(mgr, srv+"/association-type"),
"iburst": schema.DescriptionOf(mgr, srv+"/iburst"),
"prefer": schema.DescriptionOf(mgr, srv+"/prefer"),
}
}
s, errMsg := h.loadSystem(r)
data.Error = errMsg
if errMsg == "" {
@@ -246,6 +265,15 @@ func (h *ConfigureSystemHandler) OverviewDNS(w http.ResponseWriter, r *http.Requ
data := cfgDNSPageData{
PageData: newPageData(w, r, "configure-dns", "DNS Client"),
}
if mgr := h.Schema.Manager(); mgr != nil {
dr := "/ietf-system:system/dns-resolver"
data.Desc = map[string]string{
"search": schema.DescriptionOf(mgr, dr+"/search"),
"name": schema.DescriptionOf(mgr, dr+"/server/name"),
"address": schema.DescriptionOf(mgr, dr+"/server/udp-and-tcp/address"),
"port": schema.DescriptionOf(mgr, dr+"/server/udp-and-tcp/port"),
}
}
s, errMsg := h.loadSystem(r)
data.Error = errMsg
if errMsg == "" {
@@ -315,114 +343,220 @@ func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Reques
}
// SaveNTP replaces the NTP server list in the candidate datastore.
// PUT /configure/system/ntp
func (h *ConfigureSystemHandler) SaveNTP(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// ntpCandPath is the NTP container in the candidate datastore.
const ntpCandPath = candidatePath + "/ietf-system:system/ietf-system:ntp"
servers := parseNTPServers(r)
ntp := map[string]any{"enabled": true}
if len(servers) > 0 {
ntp["server"] = servers
}
body := map[string]any{"ietf-system:ntp": ntp}
if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/ntp", body); err != nil {
log.Printf("configure system ntp: %v", err)
renderSaveError(w, err)
return
}
renderSaved(w, "NTP saved")
func ntpServerCandPath(name string) string {
return ntpCandPath + "/server=" + url.PathEscape(name)
}
// SaveDNS replaces the DNS resolver config in the candidate datastore.
// PUT /configure/system/dns
func (h *ConfigureSystemHandler) SaveDNS(w http.ResponseWriter, r *http.Request) {
// ntpServerFromForm builds the RESTCONF body for one server, omitting the
// udp transport when no address is given (empty inet:host is invalid) — so a
// server can be added by name and have its address filled in via the
// expand-to-edit form afterwards.
func ntpServerFromForm(r *http.Request, name string) map[string]any {
srv := map[string]any{
"name": name,
"association-type": defaultStr(r.FormValue("association-type"), "server"),
"iburst": r.FormValue("iburst") == "true",
"prefer": r.FormValue("prefer") == "true",
}
if addr := strings.TrimSpace(r.FormValue("address")); addr != "" {
udp := map[string]any{"address": addr}
if p, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("port")), 10, 16); err == nil && p > 0 {
udp["port"] = p
}
srv["udp"] = udp
}
return srv
}
func defaultStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
// AddNTPServer adds an NTP server, creating + enabling the NTP container on
// first use, then re-renders the page.
// POST /configure/system/ntp/servers
func (h *ConfigureSystemHandler) AddNTPServer(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
search := parseSearchList(r)
servers := parseDNSServers(r)
// Omit empty lists entirely — sending null for a YANG list is invalid.
dnsResolver := map[string]any{}
if len(search) > 0 {
dnsResolver["search"] = search
}
if len(servers) > 0 {
dnsResolver["server"] = servers
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
renderSaveError(w, fmt.Errorf("server name is required"))
return
}
// PATCH the system (always present) with the NTP container nested: it is
// created + enabled on first use and the server merges into the list.
body := map[string]any{
"ietf-system:dns-resolver": dnsResolver,
"ietf-system:system": map[string]any{
"ietf-system:ntp": map[string]any{
"enabled": true,
"server": []any{ntpServerFromForm(r, name)},
},
},
}
if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/dns-resolver", body); err != nil {
log.Printf("configure system dns: %v", err)
if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil {
log.Printf("configure ntp add server: %v", err)
renderSaveError(w, err)
return
}
renderSaved(w, "DNS saved")
renderSavedRedirect(w, "NTP server added", "/configure/ntp")
}
// SaveNTPServer replaces one NTP server's configuration (per-row edit).
// POST /configure/system/ntp/servers/{name}
func (h *ConfigureSystemHandler) SaveNTPServer(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PathValue("name")
body := map[string]any{"ietf-system:server": []any{ntpServerFromForm(r, name)}}
if err := h.RC.Put(r.Context(), ntpServerCandPath(name), body); err != nil {
log.Printf("configure ntp save server %q: %v", name, err)
renderSaveError(w, err)
return
}
renderSaved(w, "Server saved")
}
// DeleteNTPServer removes an NTP server.
// DELETE /configure/system/ntp/servers/{name}
func (h *ConfigureSystemHandler) DeleteNTPServer(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := h.RC.Delete(r.Context(), ntpServerCandPath(name)); err != nil && !restconf.IsNotFound(err) {
log.Printf("configure ntp delete server %q: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "NTP server deleted", "/configure/ntp")
}
// dnsCandPath is the DNS resolver container in the candidate datastore.
const dnsCandPath = candidatePath + "/ietf-system:system/ietf-system:dns-resolver"
func dnsServerCandPath(name string) string {
return dnsCandPath + "/server=" + url.PathEscape(name)
}
func dnsServerFromForm(r *http.Request, name string) map[string]any {
srv := map[string]any{"name": name}
if addr := strings.TrimSpace(r.FormValue("address")); addr != "" {
ut := map[string]any{"address": addr}
if p, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("port")), 10, 16); err == nil && p > 0 {
ut["port"] = p
}
srv["udp-and-tcp"] = ut
}
return srv
}
// AddDNSServer adds a DNS server, creating the resolver container on first
// use, then re-renders the page.
// POST /configure/system/dns/servers
func (h *ConfigureSystemHandler) AddDNSServer(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
renderSaveError(w, fmt.Errorf("server name is required"))
return
}
body := map[string]any{
"ietf-system:system": map[string]any{
"ietf-system:dns-resolver": map[string]any{"server": []any{dnsServerFromForm(r, name)}},
},
}
if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil {
log.Printf("configure dns add server: %v", err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "DNS server added", "/configure/dns")
}
// SaveDNSServer replaces one DNS server's configuration (per-row edit).
// POST /configure/system/dns/servers/{name}
func (h *ConfigureSystemHandler) SaveDNSServer(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PathValue("name")
body := map[string]any{"ietf-system:server": []any{dnsServerFromForm(r, name)}}
if err := h.RC.Put(r.Context(), dnsServerCandPath(name), body); err != nil {
log.Printf("configure dns save server %q: %v", name, err)
renderSaveError(w, err)
return
}
renderSaved(w, "Server saved")
}
// DeleteDNSServer removes a DNS server.
// DELETE /configure/system/dns/servers/{name}
func (h *ConfigureSystemHandler) DeleteDNSServer(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := h.RC.Delete(r.Context(), dnsServerCandPath(name)); err != nil && !restconf.IsNotFound(err) {
log.Printf("configure dns delete server %q: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "DNS server deleted", "/configure/dns")
}
// AddDNSSearch appends a search domain to the resolver's search leaf-list.
// POST /configure/system/dns/search
func (h *ConfigureSystemHandler) AddDNSSearch(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
domain := strings.TrimSpace(r.FormValue("domain"))
if domain == "" {
renderSaveError(w, fmt.Errorf("domain is required"))
return
}
// POST appends to the leaf-list; if the resolver container doesn't exist
// yet, create it with this domain via a merge on the system.
err := h.RC.PostJSON(r.Context(), dnsCandPath, map[string]any{"ietf-system:search": []any{domain}})
if restconf.IsNotFound(err) {
err = h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", map[string]any{
"ietf-system:system": map[string]any{
"ietf-system:dns-resolver": map[string]any{"search": []any{domain}},
},
})
}
if err != nil {
log.Printf("configure dns add search: %v", err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "Search domain added", "/configure/dns")
}
// DeleteDNSSearch removes a search domain.
// DELETE /configure/system/dns/search/{domain}
func (h *ConfigureSystemHandler) DeleteDNSSearch(w http.ResponseWriter, r *http.Request) {
domain := r.PathValue("domain")
path := dnsCandPath + "/search=" + url.PathEscape(domain)
if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) {
log.Printf("configure dns delete search %q: %v", domain, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "Search domain removed", "/configure/dns")
}
// ─── Form parsing helpers ─────────────────────────────────────────────────────
// parseNTPServers extracts NTP server entries from form values.
// Fields: ntp_name_N, ntp_addr_N, ntp_port_N, ntp_prefer_N (checkbox).
func parseNTPServers(r *http.Request) []cfgNTPServerJSON {
var servers []cfgNTPServerJSON
for i := 0; ; i++ {
name := strings.TrimSpace(r.FormValue("ntp_name_" + strconv.Itoa(i)))
if name == "" {
break
}
addr := strings.TrimSpace(r.FormValue("ntp_addr_" + strconv.Itoa(i)))
port, _ := strconv.ParseUint(r.FormValue("ntp_port_"+strconv.Itoa(i)), 10, 16)
prefer := r.FormValue("ntp_prefer_"+strconv.Itoa(i)) == "on"
srv := cfgNTPServerJSON{
Name: name,
UDP: cfgNTPUDPJSON{Address: addr, Port: uint16(port)},
Prefer: prefer,
}
servers = append(servers, srv)
}
return servers
}
// parseSearchList extracts DNS search domains from form values.
// Fields: dns_search_N (one per domain).
func parseSearchList(r *http.Request) []string {
var search []string
for i := 0; ; i++ {
v := strings.TrimSpace(r.FormValue("dns_search_" + strconv.Itoa(i)))
if v == "" {
break
}
search = append(search, v)
}
return search
}
// parseDNSServers extracts DNS server entries from form values.
// Fields: dns_name_N, dns_addr_N, dns_port_N.
func parseDNSServers(r *http.Request) []cfgDNSServerJSON {
var servers []cfgDNSServerJSON
for i := 0; ; i++ {
name := strings.TrimSpace(r.FormValue("dns_name_" + strconv.Itoa(i)))
if name == "" {
break
}
addr := strings.TrimSpace(r.FormValue("dns_addr_" + strconv.Itoa(i)))
port, _ := strconv.ParseUint(r.FormValue("dns_port_"+strconv.Itoa(i)), 10, 16)
srv := cfgDNSServerJSON{
Name: name,
UDPAndTCP: cfgDNSAddrJSON{Address: addr, Port: uint16(port)},
}
servers = append(servers, srv)
}
return servers
}
// SavePreferences patches infix-system augmented fields (motd-banner, text-editor).
// POST /configure/system/preferences
+8 -2
View File
@@ -351,8 +351,14 @@ func New(
mux.HandleFunc("GET /configure/dns", cfgSys.OverviewDNS)
mux.HandleFunc("POST /configure/system/identity", cfgSys.SaveIdentity)
mux.HandleFunc("POST /configure/system/clock", cfgSys.SaveClock)
mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP)
mux.HandleFunc("PUT /configure/system/dns", cfgSys.SaveDNS)
mux.HandleFunc("POST /configure/system/ntp/servers", cfgSys.AddNTPServer)
mux.HandleFunc("POST /configure/system/ntp/servers/{name}", cfgSys.SaveNTPServer)
mux.HandleFunc("DELETE /configure/system/ntp/servers/{name}", cfgSys.DeleteNTPServer)
mux.HandleFunc("POST /configure/system/dns/servers", cfgSys.AddDNSServer)
mux.HandleFunc("POST /configure/system/dns/servers/{name}", cfgSys.SaveDNSServer)
mux.HandleFunc("DELETE /configure/system/dns/servers/{name}", cfgSys.DeleteDNSServer)
mux.HandleFunc("POST /configure/system/dns/search", cfgSys.AddDNSSearch)
mux.HandleFunc("DELETE /configure/system/dns/search/{domain}", cfgSys.DeleteDNSSearch)
mux.HandleFunc("POST /configure/system/preferences", cfgSys.SavePreferences)
mux.HandleFunc("GET /configure/interfaces", cfgIf.Overview)
mux.HandleFunc("POST /configure/interfaces", cfgIf.CreateInterface)
+110 -68
View File
@@ -8,75 +8,117 @@
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<form class="info-card"
hx-put="/configure/system/dns"
hx-swap="none">
<div class="card-header">DNS</div>
<section class="info-card">
<div class="card-header">Search Domains</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead><tr><th>Domain{{template "field-info" (index .Desc "search")}}</th><th></th></tr></thead>
<tbody>
{{range .DNS.Search}}
<tr>
<td>{{.}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/system/dns/search/{{. | urlquery}}"
hx-confirm="Remove search domain {{.}}?"
hx-swap="none" title="Remove domain">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{else}}
<tr><td colspan="2"><span class="text-muted">No search domains configured.</span></td></tr>
{{end}}
</tbody>
</table>
</div>
<div id="dns-search-add" hidden class="cfg-add-panel" style="padding:0.5rem 0.75rem">
<form hx-post="/configure/system/dns/search" hx-swap="none" class="user-add-inline">
<input class="cfg-input" type="text" name="domain" required placeholder="example.org">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
</form>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="dns-search-add">+ Add domain</button>
</div>
</section>
<div class="section-subtitle">Search domains</div>
<div class="data-table-wrap">
<table class="data-table cfg-table" id="dns-search-table">
<thead>
<tr><th>Domain</th><th></th></tr>
</thead>
<tbody id="dns-search-tbody">
{{range $i, $d := .DNS.Search}}
<tr>
<td><input class="cfg-input" type="text"
name="dns_search_{{$i}}" value="{{$d}}"></td>
<td>
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div style="padding: 0 0.75rem;">
<button type="button" class="btn-add-row" data-table="dns-search-tbody"
data-template="dns-search">+ Add domain</button>
</div>
<div class="section-subtitle" style="margin-top:0.75rem">Servers</div>
<div class="data-table-wrap">
<table class="data-table cfg-table" id="dns-server-table">
<thead>
<tr>
<th>Name / Key</th>
<th>Address</th>
<th class="cfg-input-sm">Port</th>
<th></th>
</tr>
</thead>
<tbody id="dns-server-tbody">
{{range $i, $s := .DNS.Servers}}
<tr>
<td><input class="cfg-input" type="text"
name="dns_name_{{$i}}" value="{{$s.Name}}" required></td>
<td><input class="cfg-input" type="text"
name="dns_addr_{{$i}}" value="{{$s.UDPAndTCP.Address}}"></td>
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
name="dns_port_{{$i}}" value="{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{end}}"
placeholder="53"></td>
<td>
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-table="dns-server-tbody"
data-template="dns-server">+ Add server</button>
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
<section class="info-card">
<div class="card-header">DNS Servers</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name{{template "field-info" (index .Desc "name")}}</th>
<th>Address{{template "field-info" (index .Desc "address")}}</th>
<th class="cfg-input-sm">Port{{template "field-info" (index .Desc "port")}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $s := .DNS.Servers}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="dns-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{$s.Name}}
</button>
</td>
<td>{{if $s.UDPAndTCP.Address}}{{$s.UDPAndTCP.Address}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{else}}<span class="text-muted">53</span>{{end}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/system/dns/servers/{{$s.Name}}"
hx-confirm="Delete DNS server {{$s.Name}}?"
hx-swap="none" title="Delete server">
{{template "icon-trash"}}
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-dns-{{$i}}">
<td colspan="4" class="key-detail-cell">
<div class="key-detail-body">
<form hx-post="/configure/system/dns/servers/{{$s.Name}}" hx-swap="none">
<table class="info-table">
<tr>
<th>Address{{template "field-info" (index $.Desc "address")}}</th>
<td><input class="cfg-input" type="text" name="address"
value="{{$s.UDPAndTCP.Address}}" placeholder="1.1.1.1"></td>
</tr>
<tr>
<th>Port{{template "field-info" (index $.Desc "port")}}</th>
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
name="port" value="{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{end}}"
placeholder="53" style="width:6rem"></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>
{{else}}
<tr><td colspan="4"><span class="text-muted">No DNS servers configured.</span></td></tr>
{{end}}
</tbody>
</table>
</div>
<div id="dns-server-add" hidden class="cfg-add-panel" style="padding:0.5rem 0.75rem">
<form hx-post="/configure/system/dns/servers" hx-swap="none" class="user-add-inline">
<input class="cfg-input" type="text" name="name" required placeholder="name (e.g. cloudflare)">
<input class="cfg-input" type="text" name="address" placeholder="address (e.g., 1.1.1.1)">
<input class="cfg-input cfg-input-sm" type="number" name="port" min="1" max="65535"
placeholder="53" style="width:6rem">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
</form>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="dns-server-add">+ Add server</button>
</div>
</section>
{{template "configure-toolbar" .}}
</div>
{{end}}
+101 -46
View File
@@ -8,52 +8,107 @@
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<form class="info-card"
hx-put="/configure/system/ntp"
hx-swap="none">
<div class="card-header">NTP Servers</div>
<div class="data-table-wrap">
<table class="data-table cfg-table" id="ntp-table">
<thead>
<tr>
<th>Name / Key</th>
<th>Address</th>
<th class="cfg-input-sm">Port</th>
<th>Prefer</th>
<th></th>
</tr>
</thead>
<tbody id="ntp-tbody">
{{range $i, $s := .NTP.Servers}}
<tr>
<td><input class="cfg-input" type="text"
name="ntp_name_{{$i}}" value="{{$s.Name}}" required></td>
<td><input class="cfg-input" type="text"
name="ntp_addr_{{$i}}" value="{{$s.UDP.Address}}"></td>
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
name="ntp_port_{{$i}}" value="{{if $s.UDP.Port}}{{$s.UDP.Port}}{{end}}"
placeholder="123"></td>
<td style="text-align:center">
<input type="checkbox" name="ntp_prefer_{{$i}}" {{if $s.Prefer}}checked{{end}}>
</td>
<td>
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row"
title="Remove">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-table="ntp-tbody"
data-template="ntp">+ Add server</button>
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
<section class="info-card">
<div class="card-header">NTP Servers</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Name{{template "field-info" (index .Desc "name")}}</th>
<th>Address{{template "field-info" (index .Desc "address")}}</th>
<th class="cfg-input-sm">Port{{template "field-info" (index .Desc "port")}}</th>
<th>Prefer{{template "field-info" (index .Desc "prefer")}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $s := .NTP.Servers}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="ntp-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{$s.Name}}
</button>
</td>
<td>{{if $s.UDP.Address}}{{$s.UDP.Address}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if $s.UDP.Port}}{{$s.UDP.Port}}{{else}}<span class="text-muted">123</span>{{end}}</td>
<td style="text-align:center">{{if $s.Prefer}}★{{end}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/system/ntp/servers/{{$s.Name}}"
hx-confirm="Delete NTP server {{$s.Name}}?"
hx-swap="none" title="Delete server">
{{template "icon-trash"}}
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-ntp-{{$i}}">
<td colspan="5" class="key-detail-cell">
<div class="key-detail-body">
<form hx-post="/configure/system/ntp/servers/{{$s.Name}}" hx-swap="none">
<table class="info-table">
<tr>
<th>Address{{template "field-info" (index $.Desc "address")}}</th>
<td><input class="cfg-input" type="text" name="address"
value="{{$s.UDP.Address}}" placeholder="address (e.g., pool.ntp.org)"></td>
</tr>
<tr>
<th>Port{{template "field-info" (index $.Desc "port")}}</th>
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
name="port" value="{{if $s.UDP.Port}}{{$s.UDP.Port}}{{end}}"
placeholder="123" style="width:6rem"></td>
</tr>
<tr>
<th>Association{{template "field-info" (index $.Desc "assoc")}}</th>
<td>
<select class="cfg-input" name="association-type">
{{range $.AssocTypes}}
<option value="{{.}}" {{if eq . $s.AssociationType}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</td>
</tr>
<tr>
<th>iburst{{template "field-info" (index $.Desc "iburst")}}</th>
<td><label><input type="checkbox" name="iburst" value="true"
{{if $s.IBurst}}checked{{end}}> Send an initial burst for faster sync</label></td>
</tr>
<tr>
<th>Prefer{{template "field-info" (index $.Desc "prefer")}}</th>
<td><label><input type="checkbox" name="prefer" value="true"
{{if $s.Prefer}}checked{{end}}> Mark as the preferred source</label></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>
{{else}}
<tr><td colspan="5"><span class="text-muted">No NTP servers configured.</span></td></tr>
{{end}}
</tbody>
</table>
</div>
{{/* Hidden add-server form, revealed by the + Add server button (data-show) */}}
<div id="ntp-add-row" hidden class="cfg-add-panel" style="padding:0.5rem 0.75rem">
<form hx-post="/configure/system/ntp/servers" hx-swap="none" class="user-add-inline">
<input class="cfg-input" type="text" name="name" required placeholder="name (label)">
<input class="cfg-input" type="text" name="address" placeholder="address (e.g., pool.ntp.org)">
<input class="cfg-input cfg-input-sm" type="number" name="port" min="1" max="65535"
placeholder="123" style="width:6rem">
<label style="white-space:nowrap"><input type="checkbox" name="prefer" value="true"> Prefer</label>
<button class="btn btn-primary btn-sm" type="submit">Add</button>
</form>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="ntp-add-row">+ Add server</button>
</div>
</section>
{{template "configure-toolbar" .}}
</div>
{{end}}