mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1327
File diff suppressed because one or more lines are too long
Vendored
+1327
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user