fix(admin): keep client chips stable under the client filter

The clients aggregate was built only from domains surviving the client
filter, so selecting a client chip collapsed every other chip's count to
the few shared domains (or made it vanish), and the chips row hid itself
entirely when the filter matched no domains - stranding the user with no
way to clear the filter.

- Snapshot now aggregates chips over the source-filtered view before the
  client filter is applied; chips stay globally counted and stable.
- Traffic page keeps the toolbar and chips visible when the filtered
  domain list is empty; the empty state only replaces the table.
- Regression test pins chips stability, including clients that share no
  domain with the selected one.
This commit is contained in:
wweir
2026-08-06 22:10:18 +08:00
parent 54f6f61e78
commit 7211ce9300
6 changed files with 1280 additions and 1253 deletions
+18 -12
View File
@@ -323,6 +323,9 @@ func (s *Stats) BindConn(conn net.Conn, domain string) {
// domain ordering, source restricts domains to one entry point, and client
// restricts them to one client IP; invalid values fall back to
// DomainSortBytes and SourceAll, and an empty client disables the filter.
// Clients aggregates per-client counters over the source-filtered view and
// is intentionally unaffected by the client filter, so the console's client
// chips stay stable while one is selected.
func (s *Stats) Snapshot(sort DomainSort, source Source, client string) TrafficSnapshot {
s.sample()
@@ -371,18 +374,9 @@ func (s *Stats) Snapshot(sort DomainSort, source Source, client string) TrafficS
ds.BytesDown = src.bytesDown
ds.LastSeen = src.lastSeen
}
if client != "" {
dc, ok := d.byClient[client] // nil map read is safe
if !ok || (dc.conns == 0 && dc.bytesUp == 0 && dc.bytesDown == 0) {
continue
}
ds.Conns = dc.conns
ds.BytesUp = dc.bytesUp
ds.BytesDown = dc.bytesDown
ds.LastSeen = dc.lastSeen
}
snap.Domains = append(snap.Domains, ds)
// Aggregate the client chips over the source-filtered view but before
// the client filter: the chip row is a navigation device and must stay
// stable (and globally counted) while a client filter is active.
for ip, c := range d.byClient {
agg := clientAgg[ip]
if agg == nil {
@@ -396,6 +390,18 @@ func (s *Stats) Snapshot(sort DomainSort, source Source, client string) TrafficS
agg.LastSeen = c.lastSeen
}
}
if client != "" {
dc, ok := d.byClient[client] // nil map read is safe
if !ok || (dc.conns == 0 && dc.bytesUp == 0 && dc.bytesDown == 0) {
continue
}
ds.Conns = dc.conns
ds.BytesUp = dc.bytesUp
ds.BytesDown = dc.bytesDown
ds.LastSeen = dc.lastSeen
}
snap.Domains = append(snap.Domains, ds)
}
sortDomains(snap.Domains, sort)
if len(snap.Domains) > snapshotTopN {
+19
View File
@@ -433,6 +433,12 @@ func TestStatsSnapshotClientFilter(t *testing.T) {
"192.168.1.10": {conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now},
},
}
s.domains["c.example"] = &domainStat{
conns: 7, bytesUp: 700, bytesDown: 700, lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.20": {conns: 7, bytesUp: 700, bytesDown: 700, lastSeen: now},
},
}
s.mu.Unlock()
// filtered by client: only that client's domains, with that client's values
@@ -452,6 +458,19 @@ func TestStatsSnapshotClientFilter(t *testing.T) {
if len(empty.Domains) != 0 {
t.Fatalf("expected no domains for unknown client, got %+v", empty.Domains)
}
// the client chips are a navigation device: they must stay stable and
// globally counted regardless of the active client filter, including
// clients that share no domain with the selected one
if len(view.Clients) != 2 {
t.Fatalf("expected both clients in chips under filter, got %+v", view.Clients)
}
if view.Clients[0].IP != "192.168.1.20" || view.Clients[0].Conns != 8 {
t.Fatalf("client .20 must keep its global conns (8) under filter, got %+v", view.Clients[0])
}
if len(empty.Clients) != 2 {
t.Fatalf("chips must survive an unknown-client filter, got %+v", empty.Clients)
}
}
func TestStatsSnapshotClientsAggregate(t *testing.T) {
+1233
View File
File diff suppressed because one or more lines are too long
-1233
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>Sower Admin</title>
<script type="module" crossorigin src="/assets/index-DYU-q3xW.js"></script>
<script type="module" crossorigin src="/assets/index-CCzmNAbo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8oAZ1sn.css">
</head>
<body>
+9 -7
View File
@@ -57,11 +57,6 @@
{#if !traffic}
<Loading />
{:else if (traffic.domains ?? []).length === 0}
<div class="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
<Inbox class="size-5" aria-hidden="true" />
<p>暂无{sourceLabel}记录。</p>
</div>
{:else}
{#if !live.connected}
<p class="mb-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
@@ -132,7 +127,13 @@
{/each}
</div>
{/if}
<Card.Card class="max-h-[70vh] overflow-auto">
{#if (traffic.domains ?? []).length === 0}
<div class="flex flex-col items-center gap-2 rounded-lg border border-dashed py-10 text-sm text-muted-foreground">
<Inbox class="size-5" aria-hidden="true" />
<p>暂无{sourceLabel}记录。</p>
</div>
{:else}
<Card.Card class="max-h-[70vh] overflow-auto">
<Table.Table>
<Table.TableHeader>
<Table.TableRow>
@@ -172,7 +173,8 @@
{/each}
</Table.TableBody>
</Table.Table>
</Card.Card>
</Card.Card>
{/if}
<p class="mt-2 text-xs text-muted-foreground">
按{activeSortLabel}排序的前 100 个{isDNSView ? 'DNS 查询域名' : sourceLabel + '域名'},实时推送。
</p>