feat(admin): switchable domain sorting on the traffic page

Traffic view now sorts domains three ways via ?sort= on /api/traffic:
- bytes (default): total traffic, high to low (the former fixed order)
- recent: most recently seen first
- conns: most connections first
Invalid or missing sort falls back to bytes; the top-100 cap is kept.

Frontend gains a segmented sort switcher next to the filter; the footer
label follows the active mode. api.ts picks up the sort query param and
is committed with the user's prettier reformat (pure formatting) since
the change is entangled with it.
This commit is contained in:
wweir
2026-08-06 18:51:28 +08:00
parent f6423a617e
commit cd5b857328
10 changed files with 1550 additions and 1435 deletions
+5 -1
View File
@@ -310,7 +310,11 @@ func (s *Server) handleTraffic(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "stats unavailable")
return
}
writeJSON(w, http.StatusOK, s.opts.Stats.Snapshot())
sort := DomainSort(r.URL.Query().Get("sort"))
if !sort.valid() {
sort = DomainSortBytes
}
writeJSON(w, http.StatusOK, s.opts.Stats.Snapshot(sort))
}
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
+53 -10
View File
@@ -31,6 +31,24 @@ const (
rateWindow = 60 * time.Second
)
// DomainSort selects the ordering of the domain stats in a snapshot.
type DomainSort string
const (
DomainSortBytes DomainSort = "bytes" // total traffic, high to low
DomainSortRecent DomainSort = "recent" // most recently seen first
DomainSortConns DomainSort = "conns" // most connections first
)
func (s DomainSort) valid() bool {
switch s {
case DomainSortBytes, DomainSortRecent, DomainSortConns:
return true
default:
return false
}
}
// DomainStat aggregates traffic for a single domain. Conns counts both proxy
// connections and DNS queries involving the domain.
type DomainStat struct {
@@ -230,8 +248,9 @@ func (s *Stats) BindConn(conn net.Conn, domain string) {
}
// Snapshot returns an immutable view of the current stats, advancing the
// history ring and evicting stale domains along the way.
func (s *Stats) Snapshot() TrafficSnapshot {
// history ring and evicting stale domains along the way. sort selects the
// domain ordering; an invalid value falls back to DomainSortBytes.
func (s *Stats) Snapshot(sort DomainSort) TrafficSnapshot {
s.sample()
snap := TrafficSnapshot{
@@ -269,20 +288,44 @@ func (s *Stats) Snapshot() TrafficSnapshot {
LastSeen: d.lastSeen,
})
}
sort.Slice(snap.Domains, func(i, j int) bool {
ti := snap.Domains[i].BytesUp + snap.Domains[i].BytesDown
tj := snap.Domains[j].BytesUp + snap.Domains[j].BytesDown
if ti == tj {
return snap.Domains[i].Domain < snap.Domains[j].Domain
}
return ti > tj
})
sortDomains(snap.Domains, sort)
if len(snap.Domains) > snapshotTopN {
snap.Domains = snap.Domains[:snapshotTopN]
}
return snap
}
// sortDomains orders domains by the selected mode: total bytes (default),
// most recently seen, or connection count. Ties break alphabetically.
func sortDomains(ds []DomainStat, mode DomainSort) {
less := func(a, b DomainStat) bool { return a.Domain < b.Domain }
switch mode {
case DomainSortRecent:
less = func(a, b DomainStat) bool {
if !a.LastSeen.Equal(b.LastSeen) {
return a.LastSeen.After(b.LastSeen)
}
return a.Domain < b.Domain
}
case DomainSortConns:
less = func(a, b DomainStat) bool {
if a.Conns != b.Conns {
return a.Conns > b.Conns
}
return a.Domain < b.Domain
}
default:
less = func(a, b DomainStat) bool {
ta, tb := a.BytesUp+a.BytesDown, b.BytesUp+b.BytesDown
if ta != tb {
return ta > tb
}
return a.Domain < b.Domain
}
}
sort.Slice(ds, func(i, j int) bool { return less(ds[i], ds[j]) })
}
// History returns a copy of the in-process history ring.
func (s *Stats) History() History {
s.histMu.Lock()
+42 -12
View File
@@ -40,7 +40,7 @@ func TestStatsWrapConnCountsBytesBeforeBind(t *testing.T) {
t.Fatalf("write: %v", err)
}
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
if snap.Conns.HTTP != 1 {
t.Fatalf("expected 1 http conn, got %d", snap.Conns.HTTP)
}
@@ -73,7 +73,7 @@ func TestStatsBindConnAttributesBytesToDomain(t *testing.T) {
t.Fatalf("write: %v", err)
}
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
if len(snap.Domains) != 1 {
t.Fatalf("expected 1 domain, got %v", snap.Domains)
}
@@ -98,7 +98,7 @@ func TestStatsBindConnAttributesBytesToDomain(t *testing.T) {
func TestStatsBindConnIgnoresUnwrappedConn(t *testing.T) {
s := newTestStats(t)
s.BindConn(&fakeConn{}, "example.com")
if len(s.Snapshot().Domains) != 0 {
if len(s.Snapshot(DomainSortBytes).Domains) != 0 {
t.Fatal("expected unwrapped conn to be ignored")
}
}
@@ -108,7 +108,7 @@ func TestStatsRecordDNS(t *testing.T) {
s.RecordDNS("Example.COM.")
s.RecordDNS("example.com.")
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
if snap.DNSQueries != 2 {
t.Fatalf("expected 2 dns queries, got %d", snap.DNSQueries)
}
@@ -127,7 +127,7 @@ func TestStatsSnapshotEvictsStaleDomains(t *testing.T) {
s.domains["fresh.example"] = &domainStat{lastSeen: time.Now()}
s.mu.Unlock()
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
if len(snap.Domains) != 1 || snap.Domains[0].Domain != "fresh.example" {
t.Fatalf("expected stale domain evicted, got %v", snap.Domains)
}
@@ -169,7 +169,7 @@ func TestStatsSnapshotSortsByTotalBytes(t *testing.T) {
s.record("big.example", func(d *domainStat) { d.bytesUp, d.bytesDown = 100, 100 })
s.record("mid.example", func(d *domainStat) { d.bytesUp, d.bytesDown = 10, 10 })
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
if len(snap.Domains) != 3 {
t.Fatalf("unexpected domains: %v", snap.Domains)
}
@@ -189,7 +189,7 @@ func newTestStats(t *testing.T) *Stats {
func TestStatsUptimeIsSeconds(t *testing.T) {
s := newTestStats(t)
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
// A fresh stats object must report a small number of seconds, not
// nanoseconds (a time.Duration would serialize as ~1e9 per second).
if snap.Uptime < 0 || snap.Uptime > 60 {
@@ -201,7 +201,7 @@ func TestStatsActiveConnsTrackedAndIdempotentClose(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{}, "http")
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
if snap.Active.HTTP != 1 || snap.Conns.HTTP != 1 {
t.Fatalf("expected 1 active/1 total http conn, got %+v / %+v", snap.Active, snap.Conns)
}
@@ -212,7 +212,7 @@ func TestStatsActiveConnsTrackedAndIdempotentClose(t *testing.T) {
if err := wrapped.Close(); err != nil { // idempotent
t.Fatalf("second close: %v", err)
}
snap = s.Snapshot()
snap = s.Snapshot(DomainSortBytes)
if snap.Active.HTTP != 0 {
t.Fatalf("expected 0 active after close, got %d", snap.Active.HTTP)
}
@@ -227,14 +227,14 @@ func TestStatsActiveConnsPerProtocol(t *testing.T) {
https := s.WrapConn(&fakeConn{}, "https")
socks := s.WrapConn(&fakeConn{}, "socks5")
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
if snap.Active.HTTP != 1 || snap.Active.HTTPS != 1 || snap.Active.Socks5 != 1 {
t.Fatalf("unexpected active conns: %+v", snap.Active)
}
_ = http.Close()
_ = https.Close()
_ = socks.Close()
snap = s.Snapshot()
snap = s.Snapshot(DomainSortBytes)
if snap.Active.HTTP != 0 || snap.Active.HTTPS != 0 || snap.Active.Socks5 != 0 {
t.Fatalf("expected all active conns released: %+v", snap.Active)
}
@@ -247,7 +247,7 @@ func TestStatsRecordRoute(t *testing.T) {
s.RecordRoute("proxy")
s.RecordRoute("proxy")
snap := s.Snapshot()
snap := s.Snapshot(DomainSortBytes)
if snap.RuleHits.Block != 1 || snap.RuleHits.Direct != 1 || snap.RuleHits.Proxy != 2 {
t.Fatalf("unexpected rule hits: %+v", snap.RuleHits)
}
@@ -312,3 +312,33 @@ func TestStatsSampleClampsLongGap(t *testing.T) {
t.Fatalf("expected clamped zero deltas, got %+v", h[0])
}
}
func TestStatsSnapshotDomainSortModes(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["big.example"] = &domainStat{conns: 10, bytesUp: 1000, bytesDown: 0, lastSeen: now.Add(-time.Minute)}
s.domains["hot.example"] = &domainStat{conns: 2, bytesUp: 100, bytesDown: 100, lastSeen: now}
s.domains["busy.example"] = &domainStat{conns: 20, bytesUp: 10, bytesDown: 10, lastSeen: now.Add(-2 * time.Minute)}
s.mu.Unlock()
byBytes := s.Snapshot(DomainSortBytes)
if byBytes.Domains[0].Domain != "big.example" {
t.Fatalf("expected big.example first by bytes, got %q", byBytes.Domains[0].Domain)
}
byRecent := s.Snapshot(DomainSortRecent)
if byRecent.Domains[0].Domain != "hot.example" {
t.Fatalf("expected hot.example first by recency, got %q", byRecent.Domains[0].Domain)
}
byConns := s.Snapshot(DomainSortConns)
if byConns.Domains[0].Domain != "busy.example" {
t.Fatalf("expected busy.example first by conns, got %q", byConns.Domains[0].Domain)
}
fallback := s.Snapshot(DomainSort("bogus"))
if fallback.Domains[0].Domain != "big.example" {
t.Fatalf("expected invalid sort to fall back to bytes, got %q", fallback.Domains[0].Domain)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1327
View File
File diff suppressed because one or more lines are too long
+1327
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<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-CcacifzN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDzv5Km6.css">
<script type="module" crossorigin src="/assets/index-JggxcI7i.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BcqEQVll.css">
</head>
<body>
<div id="app"></div>
+87 -80
View File
@@ -1,106 +1,113 @@
export type Category = 'block' | 'direct' | 'proxy'
export type Category = "block" | "direct" | "proxy";
export type DomainSort = "bytes" | "recent" | "conns";
export interface Status {
version: string
date: string
uptime: number
rules: Record<Category, number>
version: string;
date: string;
uptime: number;
rules: Record<Category, number>;
}
export interface DomainStat {
domain: string
conns: number
bytesUp: number
bytesDown: number
lastSeen: string
domain: string;
conns: number;
bytesUp: number;
bytesDown: number;
lastSeen: string;
}
export interface TrafficSnapshot {
uptime: number
dnsQueries: number
conns: { http: number; https: number; socks5: number }
active: { http: number; https: number; socks5: number }
rates: {
bytesUpPerSec: number
bytesDownPerSec: number
dnsPerSec: number
connsPerSec: number
}
ruleHits: { block: number; direct: number; proxy: number }
system: { goroutines: number; heapAlloc: number }
bytesUp: number
bytesDown: number
domains: DomainStat[]
uptime: number;
dnsQueries: number;
conns: { http: number; https: number; socks5: number };
active: { http: number; https: number; socks5: number };
rates: {
bytesUpPerSec: number;
bytesDownPerSec: number;
dnsPerSec: number;
connsPerSec: number;
};
ruleHits: { block: number; direct: number; proxy: number };
system: { goroutines: number; heapAlloc: number };
bytesUp: number;
bytesDown: number;
domains: DomainStat[];
}
export interface HistorySample {
at: string
bytesUp: number
bytesDown: number
dns: number
conns: number
active: number
activeHttp: number
activeHttps: number
activeSocks: number
block: number
direct: number
proxy: number
at: string;
bytesUp: number;
bytesDown: number;
dns: number;
conns: number;
active: number;
activeHttp: number;
activeHttps: number;
activeSocks: number;
block: number;
direct: number;
proxy: number;
}
export interface History {
samples: HistorySample[]
samples: HistorySample[];
}
export interface RulesResponse {
category: Category
rules: string[]
category: Category;
rules: string[];
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
}
constructor(
public status: number,
message: string,
) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!resp.ok) {
let message = `HTTP ${resp.status}`
try {
const body = await resp.json()
if (body && typeof body.error === 'string') message = body.error
} catch {
// keep the generic message
}
throw new ApiError(resp.status, message)
}
if (resp.status === 204) return undefined as T
return (await resp.json()) as T
const resp = await fetch(path, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!resp.ok) {
let message = `HTTP ${resp.status}`;
try {
const body = await resp.json();
if (body && typeof body.error === "string") message = body.error;
} catch {
// keep the generic message
}
throw new ApiError(resp.status, message);
}
if (resp.status === 204) return undefined as T;
return (await resp.json()) as T;
}
export const api = {
login: (password: string) =>
request<void>('/api/session', { method: 'POST', body: JSON.stringify({ password }) }),
logout: () => request<void>('/api/session', { method: 'DELETE' }),
status: () => request<Status>('/api/status'),
rules: (category: Category) => request<RulesResponse>(`/api/rules?category=${category}`),
addRules: (category: Category, rules: string[]) =>
request<RulesResponse>('/api/rules', {
method: 'POST',
body: JSON.stringify({ category, rules }),
}),
removeRules: (category: Category, rules: string[]) =>
request<RulesResponse>('/api/rules', {
method: 'DELETE',
body: JSON.stringify({ category, rules }),
}),
traffic: () => request<TrafficSnapshot>('/api/traffic'),
history: () => request<History>('/api/history'),
}
login: (password: string) =>
request<void>("/api/session", {
method: "POST",
body: JSON.stringify({ password }),
}),
logout: () => request<void>("/api/session", { method: "DELETE" }),
status: () => request<Status>("/api/status"),
rules: (category: Category) =>
request<RulesResponse>(`/api/rules?category=${category}`),
addRules: (category: Category, rules: string[]) =>
request<RulesResponse>("/api/rules", {
method: "POST",
body: JSON.stringify({ category, rules }),
}),
removeRules: (category: Category, rules: string[]) =>
request<RulesResponse>("/api/rules", {
method: "DELETE",
body: JSON.stringify({ category, rules }),
}),
traffic: (sort?: DomainSort) =>
request<TrafficSnapshot>(sort ? `/api/traffic?sort=${sort}` : "/api/traffic"),
history: () => request<History>("/api/history"),
};
+33 -2
View File
@@ -14,10 +14,20 @@
let { onUnauthorized }: { onUnauthorized: () => void } = $props()
type SortMode = 'bytes' | 'recent' | 'conns'
const sortModes: { value: SortMode; label: string }[] = [
{ value: 'bytes', label: '流量' },
{ value: 'recent', label: '最近' },
{ value: 'conns', label: '连接数' },
]
let traffic = $state<TrafficSnapshot | null>(null)
let error = $state('')
let paused = $state(false)
let filter = $state('')
let sort: SortMode = $state('bytes')
const activeSortLabel = $derived(sortModes.find((m) => m.value === sort)?.label ?? '流量')
const visibleDomains = $derived.by(() => {
const domains = traffic?.domains ?? []
@@ -29,7 +39,7 @@
async function refresh() {
if (paused) return
try {
traffic = await api.traffic()
traffic = await api.traffic(sort)
error = ''
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
@@ -68,6 +78,27 @@
class="pl-8"
/>
</div>
<div
class="flex shrink-0 items-center gap-0.5 rounded-md border bg-muted/50 p-0.5"
role="group"
aria-label="排序方式"
>
{#each sortModes as m}
<button
type="button"
class="rounded-sm px-2.5 py-1 text-xs font-medium transition-colors {sort === m.value
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={sort === m.value}
onclick={() => {
sort = m.value
void refresh()
}}
>
{m.label}
</button>
{/each}
</div>
<Button
variant="outline"
size="sm"
@@ -129,6 +160,6 @@
</Table.Table>
</Card.Card>
<p class="mt-2 text-xs text-muted-foreground">
按流量排序的前 100 个域名{paused ? ',自动刷新已暂停。' : ',每 5 秒刷新。'}
按{activeSortLabel}排序的前 100 个域名{paused ? ',自动刷新已暂停。' : ',每 5 秒刷新。'}
</p>
{/if}