feat(admin): show each domain's most recent client IP

Adds lastClientIP to every domain row on the traffic page so operators
can see at a glance who last touched a domain. Under a client filter the
column shows the filter itself; otherwise it reports the client with the
most recent activity on the domain (approximation for source-filtered
views, since the per-client map has no source dimension).
This commit is contained in:
wweir
2026-08-06 22:20:56 +08:00
parent 7211ce9300
commit 92221c2ac6
6 changed files with 89 additions and 43 deletions
+20 -6
View File
@@ -71,13 +71,16 @@ func (s Source) valid() bool {
}
// DomainStat aggregates traffic for a single domain. Conns counts both proxy
// connections and DNS queries involving the domain.
// connections and DNS queries involving the domain. LastClientIP is the most
// recent client IP for the displayed view: the filtered client itself when a
// client filter is active, otherwise the latest client across all sources.
type DomainStat struct {
Domain string `json:"domain"`
Conns uint64 `json:"conns"`
BytesUp uint64 `json:"bytesUp"`
BytesDown uint64 `json:"bytesDown"`
LastSeen time.Time `json:"lastSeen"`
Domain string `json:"domain"`
Conns uint64 `json:"conns"`
BytesUp uint64 `json:"bytesUp"`
BytesDown uint64 `json:"bytesDown"`
LastSeen time.Time `json:"lastSeen"`
LastClientIP string `json:"lastClientIP"`
}
// TrafficSnapshot is an immutable point-in-time view of traffic stats.
@@ -400,6 +403,17 @@ func (s *Stats) Snapshot(sort DomainSort, source Source, client string) TrafficS
ds.BytesUp = dc.bytesUp
ds.BytesDown = dc.bytesDown
ds.LastSeen = dc.lastSeen
ds.LastClientIP = client
} else {
// Most recent client across all sources for the domain; the
// per-client map has no source dimension, so this is the best
// approximation for a source-filtered view.
var latest time.Time
for ip, c := range d.byClient {
if c.lastSeen.After(latest) {
latest, ds.LastClientIP = c.lastSeen, ip
}
}
}
snap.Domains = append(snap.Domains, ds)
}
+27
View File
@@ -473,6 +473,33 @@ func TestStatsSnapshotClientFilter(t *testing.T) {
}
}
func TestStatsSnapshotLastClientIP(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["a.example"] = &domainStat{
lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now.Add(-2 * time.Minute)},
"192.168.1.20": {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now.Add(-1 * time.Minute)},
},
}
s.mu.Unlock()
// unfiltered: the client with the most recent activity wins
view := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(view.Domains) != 1 || view.Domains[0].LastClientIP != "192.168.1.20" {
t.Fatalf("expected .20 as last client, got %+v", view.Domains)
}
// client-filtered: the filter itself is the last client of the view
filtered := s.Snapshot(DomainSortBytes, SourceAll, "192.168.1.10")
if len(filtered.Domains) != 1 || filtered.Domains[0].LastClientIP != "192.168.1.10" {
t.Fatalf("expected filter client as last client, got %+v", filtered.Domains)
}
}
func TestStatsSnapshotClientsAggregate(t *testing.T) {
s := newTestStats(t)
now := time.Now()
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-CCzmNAbo.js"></script>
<script type="module" crossorigin src="/assets/index-5cU5DjAW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8oAZ1sn.css">
</head>
<body>
+1
View File
@@ -17,6 +17,7 @@ export interface DomainStat {
bytesUp: number;
bytesDown: number;
lastSeen: string;
lastClientIP: string;
}
export interface ClientStat {
+5 -1
View File
@@ -144,13 +144,14 @@
<Table.TableHead class={headCell + ' text-right'}>Down</Table.TableHead>
<Table.TableHead class={headCell + ' text-right'}>Total</Table.TableHead>
{/if}
<Table.TableHead class={headCell}>Client</Table.TableHead>
<Table.TableHead class={headCell + ' text-right'}>Last seen</Table.TableHead>
</Table.TableRow>
</Table.TableHeader>
<Table.TableBody>
{#if visibleDomains.length === 0}
<Table.TableRow>
<Table.TableCell colspan={isDNSView ? 3 : 6} class="py-8 text-center text-sm text-muted-foreground">
<Table.TableCell colspan={isDNSView ? 4 : 7} class="py-8 text-center text-sm text-muted-foreground">
没有匹配“{filter}”的域名。
</Table.TableCell>
</Table.TableRow>
@@ -166,6 +167,9 @@
{formatBytes(d.bytesUp + d.bytesDown)}
</Table.TableCell>
{/if}
<Table.TableCell class="max-w-[9rem] truncate font-mono text-xs text-muted-foreground" title={d.lastClientIP}>
{d.lastClientIP || '—'}
</Table.TableCell>
<Table.TableCell class="text-right text-muted-foreground tabular-nums">
{formatTime(d.lastSeen)}
</Table.TableCell>