Skip to content

Commit b98ab90

Browse files
committed
webui: add windowed totals + lifetime KPIs to home dashboard
The home page previously only surfaced instantaneous rates, so there was no way to answer "how much has this node moved in the last N hours". Add two new KPI strips driven by the existing time-window selector: - windowed: total in/out (trapezoidal integration of the rate series already fetched for the throughput chart) + peak in/out - lifetime: in/out from XraySnapshot + last config reload (relative) Also add a lifetime-total column to the top-users list (already available on XrayUser as upload_total + download_total). Backend: OverviewResp gains last_reload_at (sourced from a new Config.LastLoadTime accessor over the existing lastLoadTime field). Boot time is intentionally not duplicated here — settings page already shows it. Note on accuracy: windowed totals are computed from bucketed average rates, so they slightly under-count short bursts; peaks at long windows (30d → 4h step) are bucket-averaged peaks, not instantaneous. Good enough for v1; a monotonic cum_in/cum_out column on node_metrics would make this exact.
1 parent a15aaa3 commit b98ab90

4 files changed

Lines changed: 116 additions & 1 deletion

File tree

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ func (c *Config) NeedSyncFromServer() bool {
5252
return strings.Contains(c.PATH, "http")
5353
}
5454

55+
// LastLoadTime returns the wall-clock timestamp of the most recent
56+
// successful (or attempted — see LoadConfig) reload. Zero before the
57+
// first call.
58+
func (c *Config) LastLoadTime() time.Time {
59+
return c.lastLoadTime
60+
}
61+
5562
func (c *Config) LoadConfig(force bool) error {
5663
if c.ReloadInterval > 0 && time.Since(c.lastLoadTime).Seconds() < float64(c.ReloadInterval) && !force {
5764
c.l.Warnf("Skip Load Config, last load time: %s", c.lastLoadTime)

internal/web/handler_api.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,19 @@ type OverviewResp struct {
127127
Xray *glue.XraySnapshot `json:"xray,omitempty"`
128128
Host *ms.NodeMetrics `json:"host,omitempty"`
129129
Rules int `json:"rules"`
130+
// LastReloadAt is the wall-clock timestamp of the most recent config
131+
// reload attempt (file or HTTP). The freshness signal for routing
132+
// rules / xray inbounds — distinct from boot time, which is
133+
// surfaced on the settings page.
134+
LastReloadAt time.Time `json:"last_reload_at,omitempty"`
130135
}
131136

132137
func (s *Server) Overview(c echo.Context) error {
133138
out := OverviewResp{}
134139

135140
if s.cfg != nil {
136141
out.Rules = len(s.cfg.RelayConfigs)
142+
out.LastReloadAt = s.cfg.LastLoadTime()
137143
}
138144

139145
if p := s.xrayStatus.Load(); p != nil && *p != nil {

internal/web/webui/src/api/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ export interface OverviewResp {
4747
xray?: XraySnapshot;
4848
host?: NodeMetric;
4949
rules: number;
50+
// RFC3339; zero-value omitted by the server. Time of last config
51+
// reload attempt (file or remote HTTP).
52+
last_reload_at?: string;
5053
}
5154

5255
export interface QueryNodeMetricsResp {

internal/web/webui/src/pages/Home.tsx

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,36 @@ export default function Home() {
7575

7676
const xray = () => overview()?.xray;
7777
const host = () => overview()?.host;
78+
const lastReload = () => {
79+
const v = overview()?.last_reload_at;
80+
return v && !v.startsWith("0001-01-01") ? v : undefined;
81+
};
82+
83+
// Trapezoidal integration of rate samples → bytes over the window.
84+
// Each sample is bytes/sec averaged over its bucket; multiply by the
85+
// gap to the next sample. The first and last samples each contribute
86+
// half a gap so the total tracks the chart area.
87+
const windowedTotals = createMemo(() => {
88+
const s = series();
89+
if (s.length < 2) return { in: 0, out: 0, peakIn: 0, peakOut: 0 };
90+
let totIn = 0;
91+
let totOut = 0;
92+
let peakIn = 0;
93+
let peakOut = 0;
94+
for (let i = 0; i < s.length; i++) {
95+
const cur = s[i];
96+
if (cur.network_in > peakIn) peakIn = cur.network_in;
97+
if (cur.network_out > peakOut) peakOut = cur.network_out;
98+
if (i < s.length - 1) {
99+
const dt = s[i + 1].timestamp - cur.timestamp;
100+
if (dt > 0 && dt < 24 * 3600) {
101+
totIn += ((cur.network_in + s[i + 1].network_in) / 2) * dt;
102+
totOut += ((cur.network_out + s[i + 1].network_out) / 2) * dt;
103+
}
104+
}
105+
}
106+
return { in: totIn, out: totOut, peakIn, peakOut };
107+
});
78108

79109
const topUsers = createMemo<ScoredUser[]>(() => {
80110
const list = (users() ?? []).map<ScoredUser>((u) => ({
@@ -125,6 +155,15 @@ export default function Home() {
125155
mem={host()?.memory_usage}
126156
/>
127157

158+
<WindowedAnchor
159+
totals={windowedTotals()}
160+
haveSeries={series().length > 1}
161+
windowSec={windowSec()}
162+
lifetimeIn={xray()?.upload_total ?? 0}
163+
lifetimeOut={xray()?.download_total ?? 0}
164+
lastReload={lastReload()}
165+
/>
166+
128167
<ChartCard
129168
class="mt-3"
130169
title="throughput"
@@ -159,7 +198,11 @@ export default function Home() {
159198

160199
<div class="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-2">
161200
<Card padded={false}>
162-
<ListHeader title="top users" subtitle="by recent throughput · last 5m" linkTo="/users" />
201+
<ListHeader
202+
title="top users"
203+
subtitle="recent · last 5m · lifetime total"
204+
linkTo="/users"
205+
/>
163206
<Show
164207
when={topUsers().length}
165208
fallback={<EmptyState icon={<UsersIcon size={24} />} title="No users registered" />}
@@ -181,6 +224,15 @@ export default function Home() {
181224
<span class="w-20 shrink-0 text-right font-mono text-[11px] tabular-nums text-zinc-500">
182225
{recent > 0 ? bytes(recent) : "—"}
183226
</span>
227+
<span
228+
class="hidden w-20 shrink-0 text-right font-mono text-[11px] tabular-nums text-zinc-400 sm:inline-block dark:text-zinc-500"
229+
title="lifetime total since process start"
230+
>
231+
{(() => {
232+
const total = user.upload_total + user.download_total;
233+
return total > 0 ? bytes(total) : "—";
234+
})()}
235+
</span>
184236
<span class="w-10 shrink-0 text-right font-mono text-[11px] tabular-nums text-zinc-500">
185237
{user.tcp_conn_count}c
186238
</span>
@@ -324,6 +376,53 @@ function ThroughputAnchor(props: {
324376
);
325377
}
326378

379+
function windowLabel(sec: number): string {
380+
if (sec < 3600) return `${Math.round(sec / 60)}m`;
381+
if (sec < 86400) return `${Math.round(sec / 3600)}h`;
382+
return `${Math.round(sec / 86400)}d`;
383+
}
384+
385+
function WindowedAnchor(props: {
386+
totals: { in: number; out: number; peakIn: number; peakOut: number };
387+
haveSeries: boolean;
388+
windowSec: number;
389+
lifetimeIn: number;
390+
lifetimeOut: number;
391+
lastReload?: string;
392+
}) {
393+
const win = () => windowLabel(props.windowSec);
394+
const dash = (s: string) => (props.haveSeries ? s : "—");
395+
return (
396+
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
397+
<Card padded={false}>
398+
<div class="px-5 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-zinc-500">
399+
windowed · last {win()}
400+
</div>
401+
<div class="grid grid-cols-2 divide-x divide-zinc-200 sm:grid-cols-4 dark:divide-zinc-800">
402+
<Stat label="total in" value={dash(bytes(props.totals.in))} />
403+
<Stat label="total out" value={dash(bytes(props.totals.out))} />
404+
<Stat label="peak in" value={dash(`${bytes(props.totals.peakIn)}/s`)} />
405+
<Stat label="peak out" value={dash(`${bytes(props.totals.peakOut)}/s`)} />
406+
</div>
407+
</Card>
408+
<Card padded={false}>
409+
<div class="px-5 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-zinc-500">
410+
lifetime · since boot
411+
</div>
412+
<div class="grid grid-cols-3 divide-x divide-zinc-200 dark:divide-zinc-800">
413+
<Stat label="in" value={bytes(props.lifetimeIn)} />
414+
<Stat label="out" value={bytes(props.lifetimeOut)} />
415+
<Stat
416+
label="config"
417+
value={props.lastReload ? relTime(props.lastReload) : "—"}
418+
hint="reload"
419+
/>
420+
</div>
421+
</Card>
422+
</div>
423+
);
424+
}
425+
327426
function Stat(props: { label: string; value: string | number; hint?: string }) {
328427
return (
329428
<div class="px-4 py-4 sm:py-3.5">

0 commit comments

Comments
 (0)