Reverse zones: name hosts from other views' records; search and bulk dismiss for long lists
A reverse zone's fill (and fill-ptr, the missing-PTR counts) names an address no record of its own view uses from another view's record, so an internal reverse zone can give the PTR of a host only external has an AAAA for; New reverse zone with a view chosen lists those addresses, marked. Automatic suggestions stay per view. For hundreds of addresses: the New reverse zone list has a search; on the Zones page the public addresses fold away, have a search, show 25 at a time and Dismiss these N dismisses everything a search found in one change (reverse.dismiss takes prefixes; --dismiss takes several). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
304c89dba4
commit
bfab83da6e
11 files changed
+239
-41
No files matched your search
@@ -73,7 +73,9 @@ const (
|
||||
type DismissInput struct {
|
||||
ViewID int64 `json:"view_id,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
Prefix string `json:"prefix"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
// Prefixes dismisses (or brings back) several at once.
|
||||
Prefixes []string `json:"prefixes,omitempty"`
|
||||
}
|
||||
|
||||
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||
|
||||
+79
-16
@@ -21,6 +21,7 @@ type addrUse struct {
|
||||
addr netip.Addr
|
||||
name string // the owner name, absolute
|
||||
apex bool
|
||||
view string // another view's record (fillAddresses), else ""
|
||||
}
|
||||
|
||||
// forwardAddresses returns the addresses of the enabled A and AAAA
|
||||
@@ -46,19 +47,51 @@ func forwardAddresses(st *model.State, viewID int64) []addrUse {
|
||||
return out
|
||||
}
|
||||
|
||||
// fillAddresses returns a view's addresses (forwardAddresses), then those
|
||||
// of the other views' records that none of its own records use, marked
|
||||
// with their view: a reverse zone in the internal view can name a host
|
||||
// only the external view has a record for.
|
||||
func fillAddresses(st *model.State, viewID int64) []addrUse {
|
||||
out := forwardAddresses(st, viewID)
|
||||
if len(st.Views) == 0 {
|
||||
return out
|
||||
}
|
||||
own := map[netip.Addr]bool{}
|
||||
for _, u := range out {
|
||||
own[u.addr] = true
|
||||
}
|
||||
for _, v := range st.OrderedViews() {
|
||||
if v.ID == viewID {
|
||||
continue
|
||||
}
|
||||
for _, u := range forwardAddresses(st, v.ID) {
|
||||
if !own[u.addr] {
|
||||
u.view = v.Name
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PTRFill is a PTR record filling a reverse zone would add.
|
||||
type PTRFill struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"` // the PTR owner name
|
||||
Target string `json:"target"` // the host name it gives
|
||||
Others []string `json:"others,omitempty"` // other names with this address
|
||||
// From is the view whose record names the host, when the zone's own
|
||||
// view has none for the address.
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
||||
// lacks for the addresses of the view's A and AAAA records: one per
|
||||
// address, naming its mail or name server host when it is one, otherwise
|
||||
// a host rather than the domain itself, then the shortest name.
|
||||
// existing are the zone's records (none for a new zone).
|
||||
// a host rather than the domain itself, then the shortest name. An
|
||||
// address no record of the view uses is named from another view's
|
||||
// records (fillAddresses), marked From. existing are the zone's records
|
||||
// (none for a new zone).
|
||||
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
|
||||
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
||||
if !ok {
|
||||
@@ -73,7 +106,7 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
|
||||
servers := serverNames(st, viewID)
|
||||
byAddr := map[netip.Addr][]addrUse{}
|
||||
var order []netip.Addr
|
||||
for _, u := range forwardAddresses(st, viewID) {
|
||||
for _, u := range fillAddresses(st, viewID) {
|
||||
if !prefix.Contains(u.addr) {
|
||||
continue
|
||||
}
|
||||
@@ -110,9 +143,11 @@ func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.
|
||||
}
|
||||
return strings.Compare(x.name, y.name)
|
||||
})
|
||||
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name}
|
||||
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name, From: uses[0].view}
|
||||
for _, u := range uses[1:] {
|
||||
f.Others = append(f.Others, u.name)
|
||||
if u.name != f.Target {
|
||||
f.Others = append(f.Others, u.name)
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
@@ -143,6 +178,9 @@ func serverNames(st *model.State, viewID int64) map[string]bool {
|
||||
func fillWarnings(fills []PTRFill) []string {
|
||||
var out []string
|
||||
for _, f := range fills {
|
||||
if f.From != "" {
|
||||
out = append(out, fmt.Sprintf("%s: only view %s has a record for it, so its PTR record gives %s from there", f.Address, f.From, dnsname.Display(f.Target)))
|
||||
}
|
||||
if len(f.Others) > 0 {
|
||||
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
|
||||
}
|
||||
@@ -296,7 +334,9 @@ type ReverseOptions struct {
|
||||
// All includes what isn't suggested unasked, marked Hidden: public
|
||||
// views, views with suggestions off, dismissed networks.
|
||||
All bool
|
||||
// View narrows it to one view (by name; "" all).
|
||||
// View narrows it to one view (by name; "" all). With All, the view
|
||||
// also gets the addresses only other views' records use, marked
|
||||
// Hidden "records in VIEW": a reverse zone there can name them.
|
||||
View string
|
||||
}
|
||||
|
||||
@@ -340,7 +380,11 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
|
||||
out := []ReverseSuggestion{}
|
||||
for _, sc := range scopes {
|
||||
seen := map[netip.Prefix]bool{}
|
||||
for _, u := range forwardAddresses(st, sc.id) {
|
||||
addrs := forwardAddresses(st, sc.id)
|
||||
if opt.All && opt.View != "" {
|
||||
addrs = fillAddresses(st, sc.id)
|
||||
}
|
||||
for _, u := range addrs {
|
||||
a := u.addr
|
||||
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
|
||||
continue
|
||||
@@ -350,6 +394,9 @@ func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []n
|
||||
}
|
||||
p, group, why := suggestedPrefix(a, local)
|
||||
hidden := sc.hidden
|
||||
if hidden == "" && u.view != "" {
|
||||
hidden = "records in " + u.view
|
||||
}
|
||||
if seen[p] {
|
||||
continue
|
||||
}
|
||||
@@ -424,11 +471,23 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(in.Prefix))
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "prefix: %v", err)
|
||||
raw := in.Prefixes
|
||||
if in.Prefix != "" {
|
||||
raw = append(raw, in.Prefix)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, errf(KindBadRequest, "give the network (prefix) of the suggestion")
|
||||
}
|
||||
var prefixes []string
|
||||
for _, r := range raw {
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(r))
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "prefix: %v", err)
|
||||
}
|
||||
if s := p.Masked().String(); !slices.Contains(prefixes, s) {
|
||||
prefixes = append(prefixes, s)
|
||||
}
|
||||
}
|
||||
p = p.Masked()
|
||||
var v *model.View
|
||||
switch {
|
||||
case in.ViewID != 0:
|
||||
@@ -446,16 +505,20 @@ func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error)
|
||||
if v != nil {
|
||||
list, where = v.DismissedReverse, "view "+v.Name
|
||||
}
|
||||
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return s == p.String() })
|
||||
summary := "dismiss the reverse zone suggestion for " + p.String() + " in " + where
|
||||
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return slices.Contains(prefixes, s) })
|
||||
what := prefixes[0]
|
||||
if len(prefixes) > 1 {
|
||||
what = fmt.Sprintf("%d networks (%s, …)", len(prefixes), prefixes[0])
|
||||
}
|
||||
summary := "dismiss the reverse zone suggestion for " + what + " in " + where
|
||||
if dismiss {
|
||||
out = append(out, p.String())
|
||||
out = append(out, prefixes...)
|
||||
slices.Sort(out)
|
||||
} else {
|
||||
if len(out) == len(list) {
|
||||
return nil, errf(KindConflict, "%s isn't dismissed in %s", p, where)
|
||||
return nil, errf(KindConflict, "%s isn't dismissed in %s", what, where)
|
||||
}
|
||||
summary = "suggest a reverse zone for " + p.String() + " in " + where + " again"
|
||||
summary = "suggest a reverse zone for " + what + " in " + where + " again"
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{}
|
||||
|
||||
@@ -169,6 +169,12 @@ func TestReverseSuggestionGroups(t *testing.T) {
|
||||
t.Errorf("undismiss of one not dismissed: %v", e)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
|
||||
// Several at once.
|
||||
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) {
|
||||
t.Errorf("dismissed together, still suggested: %q", got)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
||||
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
|
||||
t.Errorf("undismissed not suggested: %q", got)
|
||||
}
|
||||
@@ -256,9 +262,44 @@ func TestReverseCandidatesAll(t *testing.T) {
|
||||
want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}),
|
||||
"internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off")
|
||||
got := list(ReverseOptions{All: true, View: "external"})
|
||||
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view")
|
||||
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 public view")
|
||||
|
||||
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)}))
|
||||
want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}),
|
||||
"internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ")
|
||||
}
|
||||
|
||||
// An address only another view has a record for: a reverse zone in the
|
||||
// internal view is offered for it, and filled from that view's record.
|
||||
func TestReverseFromOtherView(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)}))
|
||||
var got []string
|
||||
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
||||
got = append(got, s.Prefix+" "+s.Hidden)
|
||||
}
|
||||
if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") {
|
||||
t.Errorf("internal candidates %q", got)
|
||||
}
|
||||
// Not suggested unasked: the internal view's own records don't use it.
|
||||
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
|
||||
if strings.HasPrefix(s.Prefix, "2600:") {
|
||||
t.Errorf("suggested unasked: %+v", s)
|
||||
}
|
||||
}
|
||||
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
||||
st := env.state()
|
||||
var zone string
|
||||
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
|
||||
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
|
||||
zone = z.Name
|
||||
}
|
||||
}
|
||||
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
||||
t.Errorf("filled %q", p)
|
||||
}
|
||||
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
||||
t.Errorf("warnings %q", res.Warnings)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural } from "../ui.js";
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural, confirmDialog } from "../ui.js";
|
||||
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { checkDomain, reverseZoneFor, maskNetwork } from "../names.js";
|
||||
@@ -66,7 +66,20 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
// For a reverse zone: sizes to pick from, and the networks the records
|
||||
// use without a reverse zone, in every view (docs/reverse.md).
|
||||
const helper = h("div", { class: "reverse-helper" });
|
||||
// The networks per view picked ("" for all views): a chosen view's list
|
||||
// also has the addresses only other views' records use.
|
||||
const candsBy = {};
|
||||
let cands = null;
|
||||
const candQ = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { marginBottom: "6px" } });
|
||||
candQ.addEventListener("input", () => drawHelper());
|
||||
const loadCands = () => {
|
||||
const k = view.value || "";
|
||||
if (candsBy[k]) { cands = candsBy[k]; return drawHelper(); }
|
||||
get("/reverse-zones/suggestions", draftQuery({ all: 1, view: k || undefined })).catch(() => []).then((c) => {
|
||||
candsBy[k] = c;
|
||||
if ((view.value || "") === k) { cands = c; drawHelper(); }
|
||||
});
|
||||
};
|
||||
// sizeFrom is the address the size buttons work from: a record's, once a
|
||||
// network was picked, so a /48 made smaller becomes that record's /64,
|
||||
// not the /48's first one.
|
||||
@@ -159,7 +172,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
|
||||
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
|
||||
syncFill();
|
||||
if (k === "reverse" && !cands) get("/reverse-zones/suggestions", draftQuery({ all: 1 })).catch(() => []).then((c) => { cands = c; drawHelper(); });
|
||||
if (k === "reverse") loadCands();
|
||||
drawHelper();
|
||||
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
|
||||
check();
|
||||
@@ -180,9 +193,13 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
(v6 ? [[48, "a whole site (all its /64s)"], [56, "a smaller delegation"], [64, "one subnet"], [128, "one address"]] : [[16, "a /16"], [24, "one network"], [32, "one address"]])
|
||||
.map(([b, title]) => h("button", { type: "button", class: "small" + (bits === b ? " active" : ""), title, onclick: () => { name.value = maskNetwork(sizeFrom || name.value, b); changed(); } }, "/" + b)),
|
||||
sizeFrom ? h("span", { class: "muted small" }, " around " + sizeFrom) : null) : null;
|
||||
const list = (cands || []).filter((c) => !view.value || c.view === view.value);
|
||||
const shown = list.slice(0, 12);
|
||||
const q = candQ.value.trim().toLowerCase();
|
||||
// Yours first, then the networks with the most records.
|
||||
const list = (cands || []).filter((c) => !q || [c.prefix, c.zone, c.view, c.hidden, ...c.samples].join(" ").toLowerCase().includes(q))
|
||||
.sort((a, b) => (a.group === "yours" ? 0 : 1) - (b.group === "yours" ? 0 : 1) || b.fill - a.fill);
|
||||
const shown = list.slice(0, 15);
|
||||
mount(helper, sizes,
|
||||
(cands || []).length > 8 ? candQ : null,
|
||||
shown.length ? h("div", { class: "cands" },
|
||||
h("div", { class: "muted small" }, "Networks your records use without a reverse zone" + (view.value ? " in view " + view.value : "") + ": pick one, then change its size if you need"),
|
||||
shown.map((c) => h("button", { type: "button", class: "cand", title: c.why, onclick: () => {
|
||||
@@ -195,11 +212,12 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
views.length && !view.value ? h("span", { class: "muted" }, " " + c.view) : null,
|
||||
h("span", { class: "muted small" }, " " + c.fill + " PTR" + (c.samples.length ? " · " + c.samples.slice(0, 2).join(", ") : "")),
|
||||
c.hidden ? [" ", badge(c.hidden, "off")] : null)),
|
||||
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more (pick a view to narrow the list)") : null)
|
||||
list.length > shown.length ? h("div", { class: "muted small" }, "… and " + (list.length - shown.length) + " more: search to find one") : null)
|
||||
: q && cands && cands.length ? h("div", { class: "muted small" }, "No network matches " + JSON.stringify(candQ.value.trim()) + ".")
|
||||
: cands && !list.length ? h("div", { class: "muted small" }, "Every address your records use" + (view.value ? " in view " + view.value : "") + " has a reverse zone.") : null);
|
||||
};
|
||||
name.addEventListener("input", typed);
|
||||
view.addEventListener("change", () => { check(); drawHelper(); });
|
||||
view.addEventListener("change", () => { check(); if (kind === "reverse") loadCands(); else drawHelper(); });
|
||||
const glueRow = field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue).");
|
||||
const cloneRow = field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).");
|
||||
const rows = {
|
||||
@@ -364,7 +382,48 @@ function suggestPanel(sugg, views) {
|
||||
yours.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(yours) }, "Create all " + yours.length)) : null),
|
||||
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
|
||||
yours.length ? table(cols(false), yours) : null,
|
||||
pub.length ? [h("h3", null, "Public addresses: only if they're yours"),
|
||||
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's (a web host's). Create a zone for an address that is yours; dismiss the others. Each gets a zone of its own: the rest of its network belongs to others."),
|
||||
table(cols(true), pub)] : null);
|
||||
pub.length ? publicGroup(pub, cols(true), create) : null);
|
||||
}
|
||||
|
||||
// publicGroup lists the public addresses, collapsed: there can be hundreds
|
||||
// (a web host's), so it has a search, shows 25 at a time, and dismisses
|
||||
// what the search found at once.
|
||||
function publicGroup(pub, cols, create) {
|
||||
const PAGE = 25;
|
||||
let limit = PAGE;
|
||||
const q = input({ type: "search", placeholder: "Search: an address, a name or a view", class: "small", style: { width: "300px" } });
|
||||
const body = h("div");
|
||||
const match = () => {
|
||||
const t = q.value.trim().toLowerCase();
|
||||
return pub.filter((s) => !t || [s.prefix, s.zone, s.view, ...s.samples].join(" ").toLowerCase().includes(t));
|
||||
};
|
||||
const dismissAll = async (list) => {
|
||||
const byView = new Map();
|
||||
for (const s of list) {
|
||||
const k = s.view_id || 0;
|
||||
if (!byView.has(k)) byView.set(k, []);
|
||||
byView.get(k).push(s.prefix);
|
||||
}
|
||||
if (!await confirmDialog("Dismiss " + plural(list.length, "address", "addresses"), "Jarl stops suggesting reverse zones for " + (list.length === 1 ? list[0].prefix : list.length + " public addresses" + (q.value.trim() ? " matching " + JSON.stringify(q.value.trim()) : "")) + ". Bring one back with: jarl zone suggest-reverse --undismiss NETWORK --view VIEW.", "Dismiss")) return;
|
||||
try {
|
||||
const ops = [...byView].map(([vid, prefixes]) => ({ op: "reverse.dismiss", data: { view_id: vid || undefined, prefixes } }));
|
||||
toast(describeResult(await changeMany(ops, "dismiss " + plural(list.length, "reverse zone suggestion"))));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const draw = () => {
|
||||
const list = match();
|
||||
const shown = list.slice(0, limit);
|
||||
mount(body,
|
||||
h("div", { class: "compare-controls" }, q,
|
||||
list.length ? h("button", { class: "small", title: "Not yours: stop suggesting all of these", onclick: () => dismissAll(list) }, list.length === pub.length ? "Dismiss all " + list.length : "Dismiss these " + list.length) : null),
|
||||
shown.length ? table(cols, shown) : h("div", { class: "muted small" }, "No address matches."),
|
||||
list.length > shown.length ? h("button", { class: "small", onclick: () => { limit += PAGE; draw(); } }, "Show " + Math.min(PAGE, list.length - shown.length) + " more (of " + (list.length - shown.length) + ")") : null);
|
||||
};
|
||||
q.addEventListener("input", () => { limit = PAGE; draw(); });
|
||||
draw();
|
||||
return h("details", { class: "compare-only", open: pub.length <= 10 },
|
||||
h("summary", null, h("strong", null, "Public addresses: " + pub.length), h("span", { class: "muted small" }, " · create a zone only for an address that is yours; dismiss the others (a web host's)")),
|
||||
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's. Each gets a zone of its own: the rest of its network belongs to others."),
|
||||
body);
|
||||
}
|
||||
@@ -589,4 +589,4 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; }
|
||||
.size-chips { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; }
|
||||
.cands button.cand { text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cands button.cand { flex-shrink: 0; text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
Reference in new issue
Block a user