mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
+20
-6
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface DomainStat {
|
||||
bytesUp: number;
|
||||
bytesDown: number;
|
||||
lastSeen: string;
|
||||
lastClientIP: string;
|
||||
}
|
||||
|
||||
export interface ClientStat {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user