Disable zones instead of deleting them

A disabled zone stays in Jarl with its records, options and history,
still editable, but named doesn't load it; enabling it serves it as it is
then. Signed zones, and zones another view shares, can't be disabled.
jarl zone disable|enable, the zone page's Disable/Enable button.
Database schema 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-10 13:45:55 +00:00
1 parent 59baccd636
commit 89663e366b
24 files changed
+264 -26

No files matched your search

+4 -4
View File
@@ -22,10 +22,10 @@ type Zone struct {
Display string `json:"display_name"`
View string `json:"view,omitempty"`
Records int `json:"record_count"`
// Disabled is how many of the records are disabled.
Disabled int `json:"disabled_count,omitempty"`
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
RName string `json:"rname_email,omitempty"`
// DisabledRecords is how many of the records are disabled.
DisabledRecords int `json:"disabled_count,omitempty"`
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
RName string `json:"rname_email,omitempty"`
// Registrar names the registrar Jarl can manage the domain at
// ("porkbun"), when one is configured for it.
Registrar string `json:"registrar,omitempty"`
+1 -1
View File
@@ -63,7 +63,7 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Zones[k]
s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment}
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
if n.Type == model.ZonePrimary {
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
}
+4 -4
View File
@@ -477,12 +477,12 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
changedPaths[f.Path] = true
}
for _, z := range work.SortedZones() {
if z.Type != model.ZonePrimary {
continue
if z.Type != model.ZonePrimary || z.Disabled {
continue // disabled zones aren't in named: nothing to reload
}
path := pl.out.ZoneFiles[z.ID]
old := base.Zones[z.ID]
existedInNamed := old != nil && old.Type == model.ZonePrimary && baseOut.ZoneFiles[old.ID] == path
existedInNamed := old != nil && old.Type == model.ZonePrimary && !old.Disabled && baseOut.ZoneFiles[old.ID] == path
if !existedInNamed {
pl.newZones = true
pl.verify[z.ID] = z.SOA.Serial
@@ -1054,7 +1054,7 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
pl.configChanged = true
pl.reloadZones = nil
for _, z := range work.SortedZones() {
if z.Type == model.ZonePrimary {
if z.Type == model.ZonePrimary && !z.Disabled {
pl.reloadZones = append(pl.reloadZones, z)
pl.verify[z.ID] = z.SOA.Serial
}
+34
View File
@@ -145,6 +145,12 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
verb += fmt.Sprintf(" (with its %d records)", n)
}
} else if d := zoneToggle(req, z); d != nil {
if *d {
x.warnings = append(x.warnings, disabledZoneEffect(x.st, z))
return []model.Op{x.versioned(req, z.Version, "disable zone "+zoneDesc(x.st, z))}, nil
}
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
}
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
case OpRecordCreate:
@@ -1029,3 +1035,31 @@ func RedactOps(ops []model.Op) []model.Op {
}
return out
}
// zoneToggle returns the new disabled state when a zone update only
// disables or enables the zone (nil otherwise).
func zoneToggle(req Request, z *model.Zone) *bool {
var in ZoneInput
if decode(req.Data, &in) != nil || in.Disabled == nil || *in.Disabled == z.Disabled {
return nil
}
only := ZoneInput{Disabled: in.Disabled}
if string(mustJSON(in)) != string(mustJSON(only)) {
return nil
}
return in.Disabled
}
// disabledZoneEffect says what clients get once named no longer loads a
// zone: in a view that offers recursion, answers from recursion (the
// internet's version of the zone, if it has one); elsewhere, REFUSED.
func disabledZoneEffect(st *model.State, z *model.Zone) string {
where := "this server"
if v := st.ViewName(z); v != "" {
where = "view " + v
}
if st.RecursionOn(z.ViewID) {
return fmt.Sprintf("once zone %s is disabled, clients of %s that may use recursion get its names from recursion: the internet's version of the zone, if there is one", dnsname.Display(z.Name), where)
}
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
}
+2
View File
@@ -108,6 +108,8 @@ type ZoneInput struct {
// finished, off ("").
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
Comment *string `json:"comment,omitempty"`
// Disabled keeps the zone in Jarl but out of named's configuration.
Disabled *bool `json:"disabled,omitempty"`
// Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to
+3
View File
@@ -240,6 +240,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.SerialPolicy != nil {
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
}
if in.Disabled != nil {
z.Disabled = *in.Disabled
}
if in.DNSSECPolicy != nil {
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
switch strings.ToLower(p) {
+2 -2
View File
@@ -101,8 +101,8 @@ func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bump
continue
}
old := base.Zones[z.ID]
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
continue // new to named: nothing loaded to compare with
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID || old.Disabled || z.Disabled {
continue // new to named (or not in it): nothing loaded to compare with
}
path := render.ZoneFilePath(work, z, e.cfg.Paths)
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
+81
View File
@@ -0,0 +1,81 @@
package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
)
func TestZoneDisable(t *testing.T) {
env := newEnv(t)
setupZone(env)
z := env.state().ZonesNamed("example.com")[0]
confPath := filepath.Join(env.dir, "etc/named.conf")
zonePath := filepath.Join(env.dir, "zones/_default/example.com.zone")
res := env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
if res.Ops[0].Summary != "disable zone example.com" || len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "recursion") {
t.Errorf("disable: %q, warnings %v", res.Ops[0].Summary, res.Warnings)
}
conf, _ := os.ReadFile(confPath)
if strings.Contains(string(conf), "zone \"example.com\" {") || !strings.Contains(string(conf), `// zone "example.com" is disabled in Jarl`) {
t.Errorf("named.conf with the zone disabled:\n%s", conf)
}
// Still edited, and its file kept up to date.
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
if f, err := os.ReadFile(zonePath); err != nil || !strings.Contains(string(f), "www\t\tIN\tA\t192.0.2.10") {
t.Errorf("zone file while disabled: %v\n%s", err, f)
}
// Enabling brings it back.
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(false)}))
if res.Ops[0].Summary != "enable zone example.com" {
t.Errorf("enable: %q", res.Ops[0].Summary)
}
if conf, _ := os.ReadFile(confPath); !strings.Contains(string(conf), "zone \"example.com\" {") {
t.Errorf("named.conf after enabling:\n%s", conf)
}
// Reverting a disable enables the zone again.
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Zones[z.ID].Disabled {
t.Errorf("revert of a disable: %v, disabled=%v", err, env.state().Zones[z.ID].Disabled)
}
// Signed zones can't be disabled.
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "signed zone can't be disabled") {
t.Errorf("disabling a signed zone: %+v", e)
}
}
func TestZoneDisableInView(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin,
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}, AdoptZones: true}),
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external")}),
)
st := env.state()
ext := st.ViewByName("external")
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), ViewID: &ext.ID, Type: model.Ptr(model.ZoneInView), InView: model.Ptr("internal")}))
in := st.ZonesNamed("example.com")[0]
// The zone another view shares can't be disabled alone.
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, in.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "is disabled, so this zone can't share it") {
t.Errorf("disabling a shared zone: %+v", e)
}
// Without recursion, the warning says REFUSED.
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("public.example"), ViewID: &ext.ID}))
pub := env.state().FindZone("public.example", ext.ID)
res := env.submit(env.admin, NewRequest(OpZoneUpdate, pub.ID, ZoneInput{Disabled: model.Ptr(true)}))
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "view external answers REFUSED") {
t.Errorf("warnings = %v", res.Warnings)
}
}
+3
View File
@@ -128,6 +128,9 @@ type Zone struct {
// signing is being removed safely (docs/dnssec.md).
DNSSECPolicy string `json:"dnssec_policy,omitempty"`
Comment string `json:"comment"`
// Disabled zones stay in Jarl, records and all, but named doesn't
// load them: their zone statement is left out of named.conf.
Disabled bool `json:"disabled,omitempty"`
Meta
}
+6
View File
@@ -398,6 +398,12 @@ func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Outp
if v := s.ViewName(z); v != "" {
label += " [view " + v + "]"
}
if z.Disabled {
// Kept in Jarl (its zone file too, so enabling it is quick), not
// loaded by named.
w.write(fmt.Sprintf("%s// zone %s is disabled in Jarl: named doesn't load it.\n", tabs, namedconf.QuoteString(dnsname.Display(z.Name))))
return
}
w.begin("zone", z.ID, label)
if z.Comment != "" {
w.write(indentLines(commentBlock("//", z.Comment), indent))
+1 -1
View File
@@ -157,7 +157,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
var out []acme.Zone
for _, z := range st.SortedZones() {
vn := st.ViewName(z)
if z.Type == model.ZonePrimary && p.CanSeeZone(z.Name, vn) {
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
out = append(out, acme.Zone{Name: z.Name, View: vn})
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
)
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]recordCount) api.Zone {
out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID].all, Disabled: counts[z.ID].disabled}
out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID].all, DisabledRecords: counts[z.ID].disabled}
out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths)
if z.Type == model.ZonePrimary {
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
+4 -4
View File
@@ -92,14 +92,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`)
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM zones`)
if err != nil {
return nil, err
}
for rows.Next() {
z := &model.Zone{}
var ca, ua string
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
@@ -247,8 +247,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
return err
}
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
return err
+4
View File
@@ -300,6 +300,10 @@ CREATE TABLE blocklist_status (
sha256 TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT ''
);
`,
// 7: disabled zones, kept but not loaded by named.
`
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
`,
}
+5
View File
@@ -48,6 +48,9 @@ func (c *checker) checkZones() {
if err := dnsname.CheckZoneName(z.Name); err != nil {
c.zoneProblem(z, Error, "zone_name_invalid", "invalid zone name: %v", err)
}
if z.Disabled && z.Signed() {
c.zoneProblem(z, Error, "zone_disabled_signed", "a signed zone can't be disabled: validating resolvers would lose its signed answers while the parent may still have its DS record; turn signing off first")
}
k := zkey{z.ViewID, z.Name}
if seen[k] {
c.zoneProblem(z, Error, "zone_duplicate", "the zone is defined more than once in the same view")
@@ -133,6 +136,8 @@ func (c *checker) checkInView(z *model.Zone, viewPos map[int64]int) {
c.zoneProblem(z, Error, "zone_inview_invalid", "view %q has no zone %s to share", target.Name, dnsname.Display(z.Name))
} else if src.Type != model.ZonePrimary && src.Type != model.ZoneSecondary {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can only share primary or secondary zones, but %s in view %q is %s", dnsname.Display(z.Name), target.Name, src.Type)
} else if src.Disabled && !z.Disabled {
c.zoneProblem(z, Error, "zone_inview_disabled", "%s in view %q is disabled, so this zone can't share it; disable this one too, or enable that one", dnsname.Display(z.Name), target.Name)
}
for _, o := range c.s.ScopedOptions(model.ScopeZone, z.ID) {
if o.Name != "forward" && o.Name != "forwarders" {
+23 -1
View File
@@ -36,7 +36,13 @@ export async function render(el, params, query) {
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" },
zone.disabled
? h("button", { onclick: () => toggleZone(zone) }, "Enable zone")
: h("button", { disabled: !!zone.dnssec_policy, title: zone.dnssec_policy ? "A signed zone can't be disabled: turn signing off first" : "Stop serving the zone, keeping it in Jarl", onclick: () => toggleZone(zone) }, "Disable zone"),
" ",
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
const banner = zone.disabled ? h("div", { class: "msg warn" },
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null;
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
// The header and tabs show at once. Below them, switching tabs keeps the
// previous tab's content (faded) until the new one is loaded, rather
@@ -47,7 +53,7 @@ export async function render(el, params, query) {
holder.classList.add("stale");
body.className = "zone-body";
body.dataset.zone = id;
mount(el, head, tabBar, holder);
mount(el, head, banner, tabBar, holder);
if (tab === "records") await recordsTab(body, zone, query);
else if (tab === "settings") await settingsTab(body, zone);
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
@@ -653,3 +659,19 @@ function diffViewOf(r) {
if (!res) return h("div", { class: "muted" }, (r.warnings || []).join("; ") || "Nothing to import.");
return h("div", null, (r.warnings || []).length ? h("div", { class: "msg warn" }, r.warnings.join("; ")) : null, diffView(res.diff));
}
// toggleZone disables or enables a zone (asking first when disabling).
async function toggleZone(zone) {
if (!zone.disabled) {
const where = zone.view ? "view " + zone.view : "this server";
const ok = await confirmDialog("Disable zone " + zone.display_name,
"named stops serving " + zone.display_name + " in " + where + ". Its names are then answered by recursion where the view offers it (the internet's version of the zone, if any), and REFUSED elsewhere. The zone, its records and options stay in Jarl.", "Disable zone", "danger");
if (!ok) return;
}
try {
const r = await change("PATCH", "/zones/" + zone.id, { disabled: !zone.disabled });
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
+2 -2
View File
@@ -23,13 +23,13 @@ export async function render(el, params, query) {
lastList.zones = hash;
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
mount(list, table([
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
{ label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") },
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => z.pending ? "pending-" + z.pending : "" }));
].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => [z.pending ? "pending-" + z.pending : "", z.disabled ? "disabled" : ""].filter(Boolean).join(" ") }));
};
filter.addEventListener("input", draw);
viewSel.addEventListener("change", draw);