The IPv4 and IPv6 prefix lists on the AS page are each a <details> element. Its <summary> holds the list's heading and count, so a click on the heading folds or unfolds the list without a script. Both lists start open. The peer list is unchanged. The summary keeps the look of the other section headings. It hides the browser's own marker, which not every browser shows on a flex element, and draws a triangle before the heading that points down while the list is open. Model: opus-5-5
This commit is contained in:
@@ -33,6 +33,8 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-27: on the AS page, a click on the heading of the IPv4 or the IPv6
|
||||||
|
prefix list folds or unfolds the list. Both lists start open (closes #155)
|
||||||
- 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length,
|
- 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length,
|
||||||
shortest first, and prefixes of one mask length by address. They used to be
|
shortest first, and prefixes of one mask length by address. They used to be
|
||||||
listed by address (closes #153)
|
listed by address (closes #153)
|
||||||
|
|||||||
@@ -126,6 +126,26 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
}
|
}
|
||||||
|
/* A click on a prefix list's heading folds or unfolds the list. Not
|
||||||
|
every browser shows its own marker on a flex element, so the page
|
||||||
|
hides it and draws a triangle before the heading. The triangle
|
||||||
|
points down while the list is open. */
|
||||||
|
summary.prefix-header {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
summary.prefix-header::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
summary.prefix-header h2::before {
|
||||||
|
content: "\25B8";
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
details[open] > summary.prefix-header h2::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
.prefix-count {
|
.prefix-count {
|
||||||
background: #e74c3c;
|
background: #e74c3c;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -242,11 +262,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .IPv4Prefixes}}
|
{{if .IPv4Prefixes}}
|
||||||
<div class="prefix-section">
|
<details class="prefix-section" open>
|
||||||
<div class="prefix-header">
|
<summary class="prefix-header">
|
||||||
<h2>IPv4 Prefixes</h2>
|
<h2>IPv4 Prefixes</h2>
|
||||||
<span class="prefix-count">{{.IPv4Count}}</span>
|
<span class="prefix-count">{{.IPv4Count}}</span>
|
||||||
</div>
|
</summary>
|
||||||
<table class="prefix-table">
|
<table class="prefix-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -267,15 +287,15 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</details>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .IPv6Prefixes}}
|
{{if .IPv6Prefixes}}
|
||||||
<div class="prefix-section">
|
<details class="prefix-section" open>
|
||||||
<div class="prefix-header">
|
<summary class="prefix-header">
|
||||||
<h2>IPv6 Prefixes</h2>
|
<h2>IPv6 Prefixes</h2>
|
||||||
<span class="prefix-count">{{.IPv6Count}}</span>
|
<span class="prefix-count">{{.IPv6Count}}</span>
|
||||||
</div>
|
</summary>
|
||||||
<table class="prefix-table">
|
<table class="prefix-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -296,7 +316,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</details>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if eq .TotalCount 0}}
|
{{if eq .TotalCount 0}}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,3 +99,82 @@ func TestFreshnessIndicator(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestASDetailPrefixListsFold renders the AS page with one IPv4 and one IPv6
|
||||||
|
// prefix. It expects each prefix list inside its own <details> element that
|
||||||
|
// starts open and whose <summary> holds the list's heading, so that a click on
|
||||||
|
// the heading folds the list.
|
||||||
|
func TestASDetailPrefixListsFold(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
asn = 64500
|
||||||
|
prefixV4 = "192.0.2.0/24"
|
||||||
|
prefixV6 = "2001:db8::/32"
|
||||||
|
)
|
||||||
|
|
||||||
|
ipv4 := []database.LiveRoute{{Prefix: prefixV4}}
|
||||||
|
ipv6 := []database.LiveRoute{{Prefix: prefixV6}}
|
||||||
|
|
||||||
|
// The fields the AS page handler gives the template.
|
||||||
|
data := struct {
|
||||||
|
ASN *database.ASN
|
||||||
|
IPv4Prefixes []database.LiveRoute
|
||||||
|
IPv6Prefixes []database.LiveRoute
|
||||||
|
TotalCount int
|
||||||
|
IPv4Count int
|
||||||
|
IPv6Count int
|
||||||
|
Peers []database.ASPeer
|
||||||
|
PeerCount int
|
||||||
|
CSPNonce string
|
||||||
|
}{
|
||||||
|
ASN: &database.ASN{ASN: asn},
|
||||||
|
IPv4Prefixes: ipv4,
|
||||||
|
IPv6Prefixes: ipv6,
|
||||||
|
TotalCount: len(ipv4) + len(ipv6),
|
||||||
|
IPv4Count: len(ipv4),
|
||||||
|
IPv6Count: len(ipv6),
|
||||||
|
}
|
||||||
|
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
|
||||||
|
err := templates.ASDetailTemplate().Execute(&rendered, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rendering the page: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page := rendered.String()
|
||||||
|
|
||||||
|
lists := []struct{ heading, prefix string }{
|
||||||
|
{"IPv4 Prefixes", prefixV4},
|
||||||
|
{"IPv6 Prefixes", prefixV6},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, list := range lists {
|
||||||
|
before, _, found := strings.Cut(page, list.prefix)
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the page does not list %s", list.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list's section starts at the last <details before its prefix.
|
||||||
|
start := strings.LastIndex(before, "<details")
|
||||||
|
if start < 0 || strings.Contains(before[start:], "</details>") {
|
||||||
|
t.Errorf("%s: the list is not inside a <details> element", list.heading)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, section, _ := strings.Cut(before[start:], ">")
|
||||||
|
if !strings.Contains(tag, " open") {
|
||||||
|
t.Errorf("%s: the <details> element does not start open: %s>",
|
||||||
|
list.heading, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, summary, _ := strings.Cut(section, "<summary")
|
||||||
|
summary, _, _ = strings.Cut(summary, "</summary>")
|
||||||
|
|
||||||
|
if !strings.Contains(summary, list.heading) {
|
||||||
|
t.Errorf("%s: the <summary> does not hold the heading", list.heading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user