+
+
{props.label}
{props.icon && {props.icon}}
-
+
{props.value}
{props.hint && (
-
{props.hint}
+
{props.hint}
)}
);
diff --git a/internal/web/webui/src/ui/PageHeader.tsx b/internal/web/webui/src/ui/PageHeader.tsx
index 5452f9d67..7b0850e88 100644
--- a/internal/web/webui/src/ui/PageHeader.tsx
+++ b/internal/web/webui/src/ui/PageHeader.tsx
@@ -6,13 +6,14 @@ export default function PageHeader(props: {
actions?: JSX.Element;
}) {
return (
-
+
-
+
+ {">"}{" "}
{props.title}
{props.subtitle && (
-
{props.subtitle}
+
{props.subtitle}
)}
{props.actions && (
diff --git a/internal/web/webui/src/util/format.ts b/internal/web/webui/src/util/format.ts
index 6bccbbd4c..fa95fb9f5 100644
--- a/internal/web/webui/src/util/format.ts
+++ b/internal/web/webui/src/util/format.ts
@@ -24,3 +24,35 @@ export function pct(n: number | undefined): string {
if (n == null || !Number.isFinite(n)) return "—";
return `${n.toFixed(1)}%`;
}
+
+// rate formats a bytes-per-second number as a compact "12.4 MB/s".
+export function rate(bps: number | undefined | null): string {
+ if (!bps || bps < 0) return "0 B/s";
+ return `${bytes(bps)}/s`;
+}
+
+// bytesShort is like bytes() but with single-letter units and no space:
+// "12M", "1.4G", "830K". Used in chart y-axes where label width is tight.
+export function bytesShort(n: number | undefined | null): string {
+ if (!n || n < 0) return "0";
+ const u = ["", "K", "M", "G", "T", "P"];
+ let i = 0;
+ let v = n;
+ while (v >= 1024 && i < u.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${v.toFixed(v >= 100 || i === 0 ? 0 : v >= 10 ? 0 : 1)}${u[i]}`;
+}
+
+// pickStep returns a server-side bucket size (in seconds) for a given
+// time window. Aim is ≤ ~360 points per chart, regardless of window —
+// keeps payload small and uPlot snappy at 30d zoom levels.
+export function pickStep(windowSec: number): number {
+ if (windowSec <= 5 * 60) return 0; // ≤5m: raw 5s samples
+ if (windowSec <= 60 * 60) return 30; // 1h: 30s
+ if (windowSec <= 6 * 60 * 60) return 120; // 6h: 2m
+ if (windowSec <= 24 * 60 * 60) return 600; // 24h: 10m
+ if (windowSec <= 7 * 24 * 60 * 60) return 3600; // 7d: 1h
+ return 4 * 3600; // 30d: 4h
+}
diff --git a/pkg/xray/admin_api.go b/pkg/xray/admin_api.go
index 4413180a8..564e54f19 100644
--- a/pkg/xray/admin_api.go
+++ b/pkg/xray/admin_api.go
@@ -6,9 +6,36 @@ import (
"strconv"
"sync/atomic"
+ "github.com/Ehco1996/ehco/internal/glue"
"github.com/labstack/echo/v4"
)
+// Snapshot satisfies glue.XrayStatus — read by the web admin's
+// /api/v1/overview aggregate. Locks each subsystem briefly; safe to
+// call from any goroutine.
+func (xs *XrayServer) Snapshot() glue.XraySnapshot {
+ snap := glue.XraySnapshot{}
+ if xs.tracker != nil {
+ snap.Conns = xs.tracker.Count()
+ }
+ if xs.up == nil {
+ return snap
+ }
+ users := xs.up.GetAllUsers()
+ snap.Users = len(users)
+ for _, u := range users {
+ if u.Enable {
+ snap.EnabledUsers++
+ }
+ if u.running {
+ snap.RunningUsers++
+ }
+ snap.UploadTotal += atomic.LoadInt64(&u.UploadTotal)
+ snap.DownloadTotal += atomic.LoadInt64(&u.DownloadTotal)
+ }
+ return snap
+}
+
// RegisterRoutes mounts the xray management endpoints onto the given echo group.
// Authentication is provided by the surrounding web server's middleware.
//
diff --git a/pkg/xray/conn_tracker.go b/pkg/xray/conn_tracker.go
index bbc658f5d..e7b37b53a 100644
--- a/pkg/xray/conn_tracker.go
+++ b/pkg/xray/conn_tracker.go
@@ -217,6 +217,13 @@ func (t *connTracker) List(userID int) []ConnInfo {
return out
}
+// Count returns the total number of live conns across all users.
+func (t *connTracker) Count() int {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ return len(t.entries)
+}
+
// CountTCPByUser returns how many live TCP conns the user currently has.
// Used by the traffic sync to populate UserTraffic.TcpCount.
func (t *connTracker) CountTCPByUser(userID int) int {