ws/inbounds: realtime fixes + perf for 10k+ client inbounds (#4123)

* ws/inbounds: realtime fixes + perf for 10k+ client inbounds

- hub: dedup, throttle, panic-restart, deadlock fix, race tests
- client: backoff cap + slow-retry instead of giving up
- broadcast: delta-only payload, count-based invalidate fallback
- filter: fix empty online list (Inbound has no .id, use dbInbound.toInbound)
- perf: O(N²)→O(N) traffic merge, bulk delete, /setEnable endpoint
- traffic: monotonic all_time + UI clamp + propagate in delta handler
- session: persist on update/logout (fixes logout-after-password-change)
- ui: protocol tags flex, traffic bar normalize

* Remove hub_test.go file

* fix: ws hub, inbound service, and frontend correctness

- propagate DelInbound error on disable path in SetInboundEnable
- skip empty emails in updateClientTraffics to avoid constraint violations
- use consistent IN ? clause, drop redundant ErrRecordNotFound guards
- Hub.Unregister: direct removeClient fallback when channel is full
- applyClientStatsDelta: O(1) email lookup via per-inbound Map cache
- WS payload size check: Blob.size instead of .length for real byte count

* fix: chunk large IN ? queries and fix IPv6 same-origin check

* fix: chunk large IN ? queries and fix IPv6 same-origin check

* fix: unify clientStats cache, throttle clarity, hub constants

* fix(ui): align traffic/expiry cell columns across all rows

* style(ui): redesign outbounds table for visual consistency

* style(ui): redesign routing table for visual consistency

* fix:

* fix:

* fix:

* fix:

* fix:

* fix: font

* refactor: simplify outbound tone functions for consistency and maintainability

---------

Co-authored-by: lolka1333 <test123@gmail.com>
This commit is contained in:
lolka1333
2026-05-05 18:27:49 +03:00
committed by GitHub
parent 77d94b25d0
commit 8177f6dc66
16 changed files with 2373 additions and 1399 deletions

View File

@@ -1,150 +1,212 @@
/** /**
* WebSocket client for real-time updates * WebSocket client for real-time panel updates.
*
* Public API (kept stable for index.html / inbounds.html / xray.html):
* - connect() — open the connection (idempotent)
* - disconnect() — close and stop reconnecting
* - on(event, callback) — subscribe to event
* - off(event, callback) — unsubscribe
* - send(data) — send JSON to the server
* - isConnected — boolean, current state
* - reconnectAttempts — number, attempts since last success
* - maxReconnectAttempts — number, give-up threshold
*
* Built-in events:
* 'connected', 'disconnected', 'error', 'message',
* plus any server-emitted message type (status, traffic, client_stats, ...).
*/ */
class WebSocketClient { class WebSocketClient {
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
static #BASE_RECONNECT_MS = 1000;
static #MAX_RECONNECT_MS = 30_000;
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
// cadence rather than giving up forever — a panel that recovers an hour
// later should reconnect without a manual page reload.
static #SLOW_RETRY_MS = 60_000;
constructor(basePath = '') { constructor(basePath = '') {
this.basePath = basePath; this.basePath = basePath;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10; this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000; this.reconnectAttempts = 0;
this.listeners = new Map();
this.isConnected = false; this.isConnected = false;
this.ws = null;
this.shouldReconnect = true; this.shouldReconnect = true;
this.reconnectTimer = null;
this.listeners = new Map(); // event → Set<callback>
} }
// Open the connection. Safe to call repeatedly — no-op if already
// open/connecting. Re-enables reconnects if previously disabled. Cancels
// any pending reconnect timer so an external connect() can't race a
// delayed retry into spawning a second socket.
connect() { connect() {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return; return;
} }
this.shouldReconnect = true; this.shouldReconnect = true;
this.#cancelReconnect();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; this.#openSocket();
// Ensure basePath ends with '/' for proper URL construction
let basePath = this.basePath || '';
if (basePath && !basePath.endsWith('/')) {
basePath += '/';
}
const wsUrl = `${protocol}//${window.location.host}${basePath}ws`;
console.log('WebSocket connecting to:', wsUrl, 'basePath:', this.basePath);
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.isConnected = true;
this.reconnectAttempts = 0;
this.emit('connected');
};
this.ws.onmessage = (event) => {
try {
// Validate message size (prevent memory issues)
const maxMessageSize = 10 * 1024 * 1024; // 10MB
if (event.data && event.data.length > maxMessageSize) {
console.error('WebSocket message too large:', event.data.length, 'bytes');
this.ws.close();
return;
}
const message = JSON.parse(event.data);
if (!message || typeof message !== 'object') {
console.error('Invalid WebSocket message format');
return;
}
this.handleMessage(message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.emit('error', error);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.isConnected = false;
this.emit('disconnected');
if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
};
} catch (e) {
console.error('Failed to create WebSocket connection:', e);
this.emit('error', e);
}
}
handleMessage(message) {
const { type, payload, time } = message;
// Emit to specific type listeners
this.emit(type, payload, time);
// Emit to all listeners
this.emit('message', { type, payload, time });
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
const callbacks = this.listeners.get(event);
if (!callbacks.includes(callback)) {
callbacks.push(callback);
}
}
off(event, callback) {
if (!this.listeners.has(event)) {
return;
}
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
emit(event, ...args) {
if (this.listeners.has(event)) {
this.listeners.get(event).forEach(callback => {
try {
callback(...args);
} catch (e) {
console.error('Error in WebSocket event handler:', e);
}
});
}
} }
// Close the connection and stop any pending reconnect attempt. Resets the
// attempt counter so a future connect() starts fresh from the small backoff.
disconnect() { disconnect() {
this.shouldReconnect = false; this.shouldReconnect = false;
this.#cancelReconnect();
this.reconnectAttempts = 0;
if (this.ws) { if (this.ws) {
this.ws.close(); try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
this.ws = null; this.ws = null;
} }
this.isConnected = false;
} }
// Subscribe to an event. Re-subscribing the same callback is a no-op.
on(event, callback) {
if (typeof callback !== 'function') return;
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(callback);
}
// Unsubscribe from an event.
off(event, callback) {
const set = this.listeners.get(event);
if (!set) return;
set.delete(callback);
if (set.size === 0) this.listeners.delete(event);
}
// Send JSON to the server. Drops silently if not connected — callers
// should rely on connect()/server pushes rather than client-initiated sends.
send(data) { send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data)); this.ws.send(JSON.stringify(data));
} else { }
console.warn('WebSocket is not connected'); }
// ───── internals ─────
#openSocket() {
const url = this.#buildUrl();
let socket;
try {
socket = new WebSocket(url);
} catch (err) {
console.error('WebSocket: failed to construct connection', err);
this.#emit('error', err);
this.#scheduleReconnect();
return;
}
this.ws = socket;
socket.addEventListener('open', () => {
this.isConnected = true;
this.reconnectAttempts = 0;
this.#emit('connected');
});
socket.addEventListener('message', (event) => this.#onMessage(event));
socket.addEventListener('error', (event) => {
// Browsers fire 'error' before 'close' on failure. We surface it for
// consumers (so polling fallbacks can engage) but don't log every blip
// — bad networks would flood the console otherwise.
this.#emit('error', event);
});
socket.addEventListener('close', () => {
this.isConnected = false;
this.ws = null;
this.#emit('disconnected');
if (this.shouldReconnect) this.#scheduleReconnect();
});
}
#buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let basePath = this.basePath || '';
if (basePath && !basePath.endsWith('/')) basePath += '/';
return `${protocol}//${window.location.host}${basePath}ws`;
}
#onMessage(event) {
const data = event.data;
// Reject oversized payloads up front. We compare actual UTF-8 byte
// length (via Blob.size) against the limit — string.length counts
// UTF-16 code units, which can undercount real bytes by up to 4× for
// payloads with non-ASCII characters and bypass the cap.
if (typeof data === 'string') {
const byteLen = new Blob([data]).size;
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
return;
}
}
let message;
try {
message = JSON.parse(data);
} catch (err) {
console.error('WebSocket: invalid JSON message', err);
return;
}
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
console.error('WebSocket: malformed message envelope');
return;
}
this.#emit(message.type, message.payload, message.time);
this.#emit('message', message);
}
#emit(event, ...args) {
const set = this.listeners.get(event);
if (!set) return;
for (const callback of set) {
try {
callback(...args);
} catch (err) {
console.error(`WebSocket: handler for "${event}" threw`, err);
} }
} }
} }
// Create global WebSocket client instance #scheduleReconnect() {
// Safely get basePath from global scope (defined in page.html) if (!this.shouldReconnect) return;
this.#cancelReconnect();
let base;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts += 1;
// Exponential backoff inside the active window.
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
} else {
// Active window exhausted — keep trying once a minute. The page-level
// polling fallback runs in parallel; this just brings WS back when the
// network recovers.
base = WebSocketClient.#SLOW_RETRY_MS;
}
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
const delay = base * (0.75 + Math.random() * 0.5);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.#openSocket();
}, delay);
}
#cancelReconnect() {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
// Global instance — basePath is set by page.html before this script loads.
window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : ''); window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');

View File

@@ -27,6 +27,34 @@ func NewInboundController(g *gin.RouterGroup) *InboundController {
return a return a
} }
// broadcastInboundsUpdateClientLimit is the threshold past which we skip the
// full-list push over WebSocket and signal the frontend to re-fetch via REST.
// Mirrors the same heuristic used by the periodic traffic job.
const broadcastInboundsUpdateClientLimit = 5000
// broadcastInboundsUpdate fetches and broadcasts the inbound list for userId.
// At scale (10k+ clients) the marshaled JSON exceeds the WS payload ceiling,
// so we send an invalidate signal instead — frontend re-fetches via REST.
// Skipped entirely when no WebSocket clients are connected.
func (a *InboundController) broadcastInboundsUpdate(userId int) {
if !websocket.HasClients() {
return
}
inbounds, err := a.inboundService.GetInbounds(userId)
if err != nil {
return
}
totalClients := 0
for _, ib := range inbounds {
totalClients += len(ib.ClientStats)
}
if totalClients > broadcastInboundsUpdateClientLimit {
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
return
}
websocket.BroadcastInbounds(inbounds)
}
// initRouter initializes the routes for inbound-related operations. // initRouter initializes the routes for inbound-related operations.
func (a *InboundController) initRouter(g *gin.RouterGroup) { func (a *InboundController) initRouter(g *gin.RouterGroup) {
@@ -38,6 +66,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/add", a.addInbound) g.POST("/add", a.addInbound)
g.POST("/del/:id", a.delInbound) g.POST("/del/:id", a.delInbound)
g.POST("/update/:id", a.updateInbound) g.POST("/update/:id", a.updateInbound)
g.POST("/setEnable/:id", a.setInboundEnable)
g.POST("/clientIps/:email", a.getClientIps) g.POST("/clientIps/:email", a.getClientIps)
g.POST("/clearClientIps/:email", a.clearClientIps) g.POST("/clearClientIps/:email", a.clearClientIps)
g.POST("/addClient", a.addInboundClient) g.POST("/addClient", a.addInboundClient)
@@ -134,9 +163,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
} }
// Broadcast inbounds update via WebSocket a.broadcastInboundsUpdate(user.Id)
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
} }
// delInbound deletes an inbound configuration by its ID. // delInbound deletes an inbound configuration by its ID.
@@ -155,10 +182,8 @@ func (a *InboundController) delInbound(c *gin.Context) {
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
} }
// Broadcast inbounds update via WebSocket
user := session.GetLoginUser(c) user := session.GetLoginUser(c)
inbounds, _ := a.inboundService.GetInbounds(user.Id) a.broadcastInboundsUpdate(user.Id)
websocket.BroadcastInbounds(inbounds)
} }
// updateInbound updates an existing inbound configuration. // updateInbound updates an existing inbound configuration.
@@ -185,10 +210,43 @@ func (a *InboundController) updateInbound(c *gin.Context) {
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
} }
// Broadcast inbounds update via WebSocket
user := session.GetLoginUser(c) user := session.GetLoginUser(c)
inbounds, _ := a.inboundService.GetInbounds(user.Id) a.broadcastInboundsUpdate(user.Id)
websocket.BroadcastInbounds(inbounds) }
// setInboundEnable flips only the enable flag of an inbound. This is a
// dedicated endpoint because the regular update path serialises the entire
// settings JSON (every client) — far too heavy for an interactive switch
// on inbounds with thousands of clients. Frontend optimistically updates
// the UI; we just persist + sync xray + nudge other open admin sessions.
func (a *InboundController) setInboundEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
type form struct {
Enable bool `json:"enable" form:"enable"`
}
var f form
if err := c.ShouldBind(&f); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.inboundService.SetInboundEnable(id, f.Enable)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
// instead of fetching + serialising the whole inbound list. Other open
// sessions re-fetch via REST. The toggling admin's own UI already
// updated optimistically.
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
} }
// getClientIps retrieves the IP addresses associated with a client by email. // getClientIps retrieves the IP addresses associated with a client by email.

View File

@@ -10,7 +10,6 @@ import (
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session" "github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -95,8 +94,7 @@ func (a *IndexController) login(c *gin.Context) {
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c)) logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1) a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
session.SetLoginUser(c, user) if err := session.SetLoginUser(c, user); err != nil {
if err := sessions.Default(c).Save(); err != nil {
logger.Warning("Unable to save session:", err) logger.Warning("Unable to save session:", err)
return return
} }
@@ -111,9 +109,8 @@ func (a *IndexController) logout(c *gin.Context) {
if user != nil { if user != nil {
logger.Infof("%s logged out successfully", user.Username) logger.Infof("%s logged out successfully", user.Username)
} }
session.ClearSession(c) if err := session.ClearSession(c); err != nil {
if err := sessions.Default(c).Save(); err != nil { logger.Warning("Unable to clear session on logout:", err)
logger.Warning("Unable to save session after clearing:", err)
} }
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")) c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
} }

View File

@@ -99,7 +99,9 @@ func (a *SettingController) updateUser(c *gin.Context) {
if err == nil { if err == nil {
user.Username = form.NewUsername user.Username = form.NewUsername
user.Password, _ = crypto.HashPasswordAsBcrypt(form.NewPassword) user.Password, _ = crypto.HashPasswordAsBcrypt(form.NewPassword)
session.SetLoginUser(c, user) if saveErr := session.SetLoginUser(c, user); saveErr != nil {
err = saveErr
}
} }
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
} }

View File

@@ -1,7 +1,9 @@
package controller package controller
import ( import (
"net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -16,105 +18,80 @@ import (
) )
const ( const (
// Time allowed to write a message to the peer
writeWait = 10 * time.Second writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer
pongWait = 60 * time.Second pongWait = 60 * time.Second
// Send pings to peer with this period (must be less than pongWait)
pingPeriod = (pongWait * 9) / 10 pingPeriod = (pongWait * 9) / 10
clientReadLimit = 512
// Maximum message size allowed from peer
maxMessageSize = 512
) )
var upgrader = ws.Upgrader{ var upgrader = ws.Upgrader{
ReadBufferSize: 32768, ReadBufferSize: 32768,
WriteBufferSize: 32768, WriteBufferSize: 32768,
EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it EnableCompression: true,
CheckOrigin: checkSameOrigin,
}
CheckOrigin: func(r *http.Request) bool { // checkSameOrigin allows requests with no Origin header (same-origin or non-browser
// Check origin for security // clients) and otherwise requires the Origin hostname to match the request hostname.
// Comparison is case-insensitive (RFC 7230 §2.7.3) and ignores port differences
// (the panel often sits behind a reverse proxy on a different port).
func checkSameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
if origin == "" { if origin == "" {
// Allow connections without Origin header (same-origin requests)
return true return true
} }
// Get the host from the request u, err := url.Parse(origin)
host := r.Host if err != nil || u.Hostname() == "" {
// Extract scheme and host from origin
originURL := origin
// Simple check: origin should match the request host
// This prevents cross-origin WebSocket hijacking
if strings.HasPrefix(originURL, "http://") || strings.HasPrefix(originURL, "https://") {
// Extract host from origin
originHost := strings.TrimPrefix(strings.TrimPrefix(originURL, "http://"), "https://")
if idx := strings.Index(originHost, "/"); idx != -1 {
originHost = originHost[:idx]
}
if idx := strings.Index(originHost, ":"); idx != -1 {
originHost = originHost[:idx]
}
// Compare hosts (without port)
requestHost := host
if idx := strings.Index(requestHost, ":"); idx != -1 {
requestHost = requestHost[:idx]
}
return originHost == requestHost || originHost == "" || requestHost == ""
}
return false return false
}, }
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
// IPv6 literals without a port arrive as "[::1]"; net.SplitHostPort
// fails in that case while url.Hostname() returns the address without
// brackets. Strip them so same-origin checks pass for bare IPv6 hosts.
host = r.Host
if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' {
host = host[1 : len(host)-1]
}
}
return strings.EqualFold(u.Hostname(), host)
} }
// WebSocketController handles WebSocket connections for real-time updates // WebSocketController handles WebSocket connections for real-time updates.
type WebSocketController struct { type WebSocketController struct {
BaseController BaseController
hub *websocket.Hub hub *websocket.Hub
} }
// NewWebSocketController creates a new WebSocket controller // NewWebSocketController creates a new WebSocket controller.
func NewWebSocketController(hub *websocket.Hub) *WebSocketController { func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
return &WebSocketController{ return &WebSocketController{hub: hub}
hub: hub,
}
} }
// HandleWebSocket handles WebSocket connections // HandleWebSocket upgrades the HTTP connection and starts the read/write pumps.
func (w *WebSocketController) HandleWebSocket(c *gin.Context) { func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
// Check authentication
if !session.IsLogin(c) { if !session.IsLogin(c) {
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c)) logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
c.AbortWithStatus(http.StatusUnauthorized) c.AbortWithStatus(http.StatusUnauthorized)
return return
} }
// Upgrade connection to WebSocket
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
logger.Error("Failed to upgrade WebSocket connection:", err) logger.Error("Failed to upgrade WebSocket connection:", err)
return return
} }
// Create client client := websocket.NewClient(uuid.New().String())
clientID := uuid.New().String()
client := &websocket.Client{
ID: clientID,
Hub: w.hub,
Send: make(chan []byte, 512), // Increased from 256 to 512 to prevent overflow
Topics: make(map[websocket.MessageType]bool),
}
// Register client
w.hub.Register(client) w.hub.Register(client)
logger.Debugf("WebSocket client %s registered from %s", clientID, getRemoteIp(c)) logger.Debugf("WebSocket client %s registered from %s", client.ID, getRemoteIp(c))
// Start goroutines for reading and writing
go w.writePump(client, conn) go w.writePump(client, conn)
go w.readPump(client, conn) go w.readPump(client, conn)
} }
// readPump pumps messages from the WebSocket connection to the hub // readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
// running. Clients send no commands today; frames are discarded.
func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) { func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
defer func() { defer func() {
if r := common.Recover("WebSocket readPump panic"); r != nil { if r := common.Recover("WebSocket readPump panic"); r != nil {
@@ -124,35 +101,23 @@ func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn)
conn.Close() conn.Close()
}() }()
conn.SetReadLimit(clientReadLimit)
conn.SetReadDeadline(time.Now().Add(pongWait)) conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error { conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait)) return conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
}) })
conn.SetReadLimit(maxMessageSize)
for { for {
_, message, err := conn.ReadMessage() if _, _, err := conn.ReadMessage(); err != nil {
if err != nil {
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) { if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err) logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
} }
break return
} }
// Validate message size
if len(message) > maxMessageSize {
logger.Warningf("WebSocket message from client %s exceeds max size: %d bytes", client.ID, len(message))
continue
}
// Handle incoming messages (e.g., subscription requests)
// For now, we'll just log them
logger.Debugf("Received WebSocket message from client %s: %s", client.ID, string(message))
} }
} }
// writePump pumps messages from the hub to the WebSocket connection // writePump pushes hub messages to the connection and emits keepalive pings.
func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) { func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
ticker := time.NewTicker(pingPeriod) ticker := time.NewTicker(pingPeriod)
defer func() { defer func() {
@@ -165,17 +130,13 @@ func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn)
for { for {
select { select {
case message, ok := <-client.Send: case msg, ok := <-client.Send:
conn.SetWriteDeadline(time.Now().Add(writeWait)) conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok { if !ok {
// Hub closed the channel
conn.WriteMessage(ws.CloseMessage, []byte{}) conn.WriteMessage(ws.CloseMessage, []byte{})
return return
} }
if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
// Send each message individually (no batching)
// This ensures each JSON message is sent separately and can be parsed correctly
if err := conn.WriteMessage(ws.TextMessage, message); err != nil {
logger.Debugf("WebSocket write error for client %s: %v", client.ID, err) logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
return return
} }

View File

@@ -93,27 +93,22 @@
</tr> </tr>
</table> </table>
</template> </template>
<table> <div class="tr-table-box">
<tr class="tr-table-box"> <div class="tr-table-rt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
<td class="tr-table-rt"> [[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]] </td> <div class="tr-table-bar" v-if="!client.enable">
<td class="tr-table-bar" v-if="!client.enable"> <a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" </div>
:percent="statsProgress(record, client.email)" /> <div class="tr-table-bar" v-else-if="client.totalGB > 0">
</td> <a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
<td class="tr-table-bar" v-else-if="client.totalGB > 0"> </div>
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" <div v-else class="infinite-bar tr-table-bar">
:status="isClientDepleted(record, client.email)? 'exception' : ''"
:percent="statsProgress(record, client.email)" />
</td>
<td v-else class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :percent="100"></a-progress> <a-progress :show-info="false" :percent="100"></a-progress>
</td> </div>
<td class="tr-table-lt"> <div class="tr-table-lt">
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template> <template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
<span v-else class="tr-infinity-ch">&infin;</span> <span v-else class="tr-infinity-ch">&infin;</span>
</td> </div>
</tr> </div>
</table>
</a-popover> </a-popover>
</template> </template>
@@ -127,16 +122,13 @@
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span> <span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span> <span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
</template> </template>
<table> <div class="tr-table-box">
<tr class="tr-table-box"> <div class="tr-table-rt">[[ IntlUtil.formatRelativeTime(client.expiryTime) ]]</div>
<td class="tr-table-rt"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </td> <div class="infinite-bar tr-table-bar">
<td class="infinite-bar tr-table-bar"> <a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" </div>
:percent="expireProgress(client.expiryTime, client.reset)" /> <div class="tr-table-lt">[[ client.reset + "d" ]]</div>
</td> </div>
<td class="tr-table-lt">[[ client.reset + "d" ]]</td>
</tr>
</table>
</a-popover> </a-popover>
</template> </template>
<template v-else> <template v-else>

View File

@@ -1,238 +1,302 @@
{{define "component/sortableTableTrigger"}} {{define "component/sortableTableTrigger"}}
<a-icon type="drag" class="sortable-icon" :style="{ cursor: 'move' }" @mouseup="mouseUpHandler" <a-icon type="drag" class="sortable-icon"
@mousedown="mouseDownHandler" @click="clickHandler" /> role="button" tabindex="0"
:aria-label="ariaLabel"
@pointerdown="onPointerDown"
@keydown="onKeyDown" />
{{end}} {{end}}
{{define "component/aTableSortable"}} {{define "component/aTableSortable"}}
<script> <script>
const DRAGGABLE_ROW_CLASS = 'draggable-row'; /**
const findParentRowElement = (el) => { * Sortable a-table — drag-to-reorder rows using Pointer Events.
if (!el || !el.tagName) { *
return null; * Why a rewrite:
} else if (el.classList.contains(DRAGGABLE_ROW_CLASS)) { * - Old impl set `draggable: true` on every row, which (a) broke text
return el; * selection inside cells, (b) let HTML5 start a drag from anywhere on
} else if (el.parentNode) { * the row even when the state machine wasn't primed, producing
return findParentRowElement(el.parentNode); * "phantom drags" that didn't reorder anything.
} else { * - HTML5 drag has no touch support on most mobile browsers and no
return null; * keyboard fallback at all.
} * - The drag-image hack cloned the entire table — slow on big lists.
} *
* New design:
* - Only the explicit drag handle initiates a drag, via Pointer Events
* (one API for mouse + touch + pen). Rows are not draggable.
* - During drag, `data-source` is reordered live: the source row visually
* slides into the target slot and other rows shift around it. The live
* reorder IS the visual feedback — no separate floating preview.
* - On commit, emits `onsort(sourceIndex, targetIndex)` — same event name
* and signature as before, so existing call sites stay unchanged.
* - Keyboard support: the handle is focusable; ArrowUp / ArrowDown move
* the row by one; Escape cancels a pointer-drag in progress.
*/
const ROW_CLASS = 'sortable-row';
Vue.component('a-table-sortable', { Vue.component('a-table-sortable', {
data() { data() {
return { return {
sortingElementIndex: null, // null when idle. While dragging:
newElementIndex: null, // { sourceIndex, targetIndex, pointerId, sourceKey }
drag: null,
}; };
}, },
props: { props: {
'data-source': { 'data-source': { type: undefined, required: false },
type: undefined, 'customRow': { type: undefined, required: false },
required: false, 'row-key': { type: undefined, required: false },
},
'customRow': {
type: undefined,
required: false,
}
}, },
inheritAttrs: false, inheritAttrs: false,
provide() { provide() {
const sortable = {} const sortable = {};
Object.defineProperty(sortable, "setSortableIndex", { // Methods exposed to the trigger child via inject. Defined as getters
// so `this` binds to the component instance, not the plain object.
Object.defineProperty(sortable, 'startDrag', {
enumerable: true, enumerable: true,
get: () => this.setCurrentSortableIndex, get: () => this.startDrag,
}); });
Object.defineProperty(sortable, "resetSortableIndex", { Object.defineProperty(sortable, 'moveByKeyboard', {
enumerable: true, enumerable: true,
get: () => this.resetSortableIndex, get: () => this.moveByKeyboard,
}); });
return { return { sortable };
sortable, },
beforeDestroy() {
this.detachPointerListeners();
},
methods: {
isDragging() { return this.drag !== null; },
// Resolve the row key for a record. Used to identify the source row
// even after data-source is reordered live during drag.
keyOf(record, fallback) {
const rk = this.rowKey;
if (typeof rk === 'function') return rk(record);
if (typeof rk === 'string') return record && record[rk];
return fallback;
},
startDrag(e, sourceIndex) {
// Primary button only (mouse left / first touch).
if (e.button != null && e.button !== 0) return;
e.preventDefault();
const record = this.dataSource && this.dataSource[sourceIndex];
this.drag = {
sourceIndex,
targetIndex: sourceIndex,
pointerId: e.pointerId,
sourceKey: this.keyOf(record, sourceIndex),
};
// Capture the pointer so move/up keep firing even if the cursor leaves
// the icon. Try/catch because some older browsers throw on capture.
if (e.target && typeof e.target.setPointerCapture === 'function' && e.pointerId != null) {
try { e.target.setPointerCapture(e.pointerId); } catch (_) {}
}
this.attachPointerListeners();
},
attachPointerListeners() {
this._onMove = (ev) => this.onPointerMove(ev);
this._onUp = (ev) => this.onPointerUp(ev);
this._onCancel = (ev) => this.cancelDrag(ev);
document.addEventListener('pointermove', this._onMove, true);
document.addEventListener('pointerup', this._onUp, true);
document.addEventListener('pointercancel', this._onCancel, true);
document.addEventListener('keydown', this._onCancel, true);
},
detachPointerListeners() {
if (!this._onMove) return;
document.removeEventListener('pointermove', this._onMove, true);
document.removeEventListener('pointerup', this._onUp, true);
document.removeEventListener('pointercancel', this._onCancel, true);
document.removeEventListener('keydown', this._onCancel, true);
this._onMove = this._onUp = this._onCancel = null;
},
onPointerMove(e) {
if (!this.drag) return;
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
// Hit-test: find which row the pointer Y is inside (or closest to).
const rows = this.$el.querySelectorAll('tr.' + ROW_CLASS);
if (!rows.length) return;
const y = e.clientY;
const firstRect = rows[0].getBoundingClientRect();
const lastRect = rows[rows.length - 1].getBoundingClientRect();
let target = this.drag.targetIndex;
if (y < firstRect.top) {
target = 0;
} else if (y > lastRect.bottom) {
target = rows.length - 1;
} else {
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect();
if (y >= rect.top && y <= rect.bottom) {
target = i;
break;
}
}
}
if (target !== this.drag.targetIndex) {
this.drag = Object.assign({}, this.drag, { targetIndex: target });
} }
}, },
render: function(createElement) { onPointerUp(e) {
return createElement('a-table', { if (!this.drag) return;
class: { if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
'ant-table-is-sorting': this.isDragging(), this.commitDrag();
}, },
props: { commitDrag() {
...this.$attrs, const d = this.drag;
this.detachPointerListeners();
this.drag = null;
if (d && d.sourceIndex !== d.targetIndex) {
this.$emit('onsort', d.sourceIndex, d.targetIndex);
}
},
cancelDrag(e) {
// Triggered by pointercancel and keydown handlers. For keydown, only
// act on Escape; otherwise let the event flow to other listeners.
if (e && e.type === 'keydown' && e.key !== 'Escape') return;
this.detachPointerListeners();
this.drag = null;
},
// Keyboard reorder: commit immediately by emitting onsort. No "preview"
// state needed since the move is one row up or down.
moveByKeyboard(direction, sourceIndex) {
const target = sourceIndex + direction;
if (target < 0 || target >= (this.dataSource || []).length) return;
this.$emit('onsort', sourceIndex, target);
},
customRowRender(record, index) {
const parent = (typeof this.customRow === 'function')
? (this.customRow(record, index) || {})
: {};
const d = this.drag;
const isSource = d && this.keyOf(record, index) === d.sourceKey;
return Object.assign({}, parent, {
// CRITICAL: no `draggable: true`. Drag is initiated only by the
// handle icon. Leaves text-selection on cells working normally.
attrs: Object.assign({}, parent.attrs || {}),
class: Object.assign({}, parent.class || {}, {
[ROW_CLASS]: true,
'sortable-source-row': !!isSource,
}),
});
},
},
computed: {
// Render-data: dataSource with the source row spliced into targetIndex.
// When idle or when target equals source, returns the original list
// unchanged so Ant Design's table treats this as a stable reference.
records() {
const d = this.drag;
if (!d || d.sourceIndex === d.targetIndex) return this.dataSource;
const list = (this.dataSource || []).slice();
const [item] = list.splice(d.sourceIndex, 1);
list.splice(d.targetIndex, 0, item);
return list;
},
},
render(h) {
return h('a-table', {
class: { 'sortable-table': true, 'sortable-table-dragging': this.isDragging() },
props: Object.assign({}, this.$attrs, {
'data-source': this.records, 'data-source': this.records,
'row-key': this.rowKey,
customRow: (record, index) => this.customRowRender(record, index), customRow: (record, index) => this.customRowRender(record, index),
},
on: this.$listeners,
nativeOn: {
drop: (e) => this.dropHandler(e),
},
scopedSlots: this.$scopedSlots,
locale: { locale: {
filterConfirm: `{{ i18n "confirm" }}`, filterConfirm: `{{ i18n "confirm" }}`,
filterReset: `{{ i18n "reset" }}`, filterReset: `{{ i18n "reset" }}`,
emptyText: `{{ i18n "noData" }}` emptyText: `{{ i18n "noData" }}`,
}
}, this.$slots.default, )
}, },
created() { }),
this.$memoSort = {}; on: this.$listeners,
scopedSlots: this.$scopedSlots,
}, this.$slots.default);
}, },
methods: {
isDragging() {
const currentIndex = this.sortingElementIndex;
return currentIndex !== null && currentIndex !== undefined;
},
resetSortableIndex(e, index) {
this.sortingElementIndex = null;
this.newElementIndex = null;
this.$memoSort = {};
},
setCurrentSortableIndex(e, index) {
this.sortingElementIndex = index;
},
dragStartHandler(e, index) {
if (!this.isDragging()) {
e.preventDefault();
return;
}
const hideDragImage = this.$el.cloneNode(true);
hideDragImage.id = "hideDragImage-hide";
hideDragImage.style.opacity = 0;
e.dataTransfer.setDragImage(hideDragImage, 0, 0);
},
dragStopHandler(e, index) {
const hideDragImage = document.getElementById('hideDragImage-hide');
if (hideDragImage) hideDragImage.remove();
this.resetSortableIndex(e, index);
},
dragOverHandler(e, index) {
if (!this.isDragging()) {
return;
}
e.preventDefault();
const currentIndex = this.sortingElementIndex;
if (index === currentIndex) {
this.newElementIndex = null;
return;
}
const row = findParentRowElement(e.target);
if (!row) {
return;
}
const rect = row.getBoundingClientRect();
const offsetTop = e.pageY - rect.top;
if (offsetTop < rect.height / 2) {
this.newElementIndex = Math.max(index - 1, 0);
} else {
this.newElementIndex = index;
}
},
dropHandler(e) {
if (this.isDragging()) {
this.$emit('onsort', this.sortingElementIndex, this.newElementIndex);
}
},
customRowRender(record, index) {
const parentMethodResult = this.customRow?.(record, index) || {};
const newIndex = this.newElementIndex;
const currentIndex = this.sortingElementIndex;
return {
...parentMethodResult,
attrs: {
...(parentMethodResult?.attrs || {}),
draggable: true,
},
on: {
...(parentMethodResult?.on || {}),
dragstart: (e) => this.dragStartHandler(e, index),
dragend: (e) => this.dragStopHandler(e, index),
dragover: (e) => this.dragOverHandler(e, index),
},
class: {
...(parentMethodResult?.class || {}),
[DRAGGABLE_ROW_CLASS]: true,
['dragging']: this.isDragging() ? (newIndex === null ? index === currentIndex : index === newIndex) :
false,
},
};
}
},
computed: {
records() {
const newIndex = this.newElementIndex;
const currentIndex = this.sortingElementIndex;
if (!this.isDragging() || newIndex === null || currentIndex === newIndex) {
return this.dataSource;
}
if (this.$memoSort.newIndex === newIndex) {
return this.$memoSort.list;
}
let list = [...this.dataSource];
list.splice(newIndex, 0, list.splice(currentIndex, 1)[0]);
this.$memoSort = {
newIndex,
list,
};
return list;
}
}
}); });
Vue.component('a-table-sort-trigger', { Vue.component('a-table-sort-trigger', {
template: `{{template "component/sortableTableTrigger" .}}`, template: `{{template "component/sortableTableTrigger" .}}`,
props: { props: {
'item-index': { 'item-index': { type: undefined, required: false },
type: undefined,
required: false
}
}, },
inject: ['sortable'], inject: ['sortable'],
computed: {
ariaLabel() {
// Localised label is overkill for an internal a11y string; English is
// fine here and matches screen-reader expectations across locales.
return 'Drag to reorder row ' + (((this.itemIndex == null ? 0 : this.itemIndex) + 1));
},
},
methods: { methods: {
mouseDownHandler(e) { onPointerDown(e) {
if (this.sortable) { if (this.sortable && this.sortable.startDrag) {
this.sortable.setSortableIndex(e, this.itemIndex); this.sortable.startDrag(e, this.itemIndex);
} }
}, },
mouseUpHandler(e) { onKeyDown(e) {
if (this.sortable) { if (!this.sortable || !this.sortable.moveByKeyboard) return;
this.sortable.resetSortableIndex(e, this.itemIndex); if (e.key === 'ArrowUp') {
}
},
clickHandler(e) {
e.preventDefault(); e.preventDefault();
this.sortable.moveByKeyboard(-1, this.itemIndex);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.sortable.moveByKeyboard(+1, this.itemIndex);
}
}, },
} },
}) });
</script> </script>
<style> <style>
@media only screen and (max-width: 767px) { /* Drag handle — focusable, keyboard-accessible, touch-friendly hit area.
`touch-action: none` is critical: it tells the browser not to interpret
touch on the icon as a scroll/zoom gesture, so pointermove fires for
drag-tracking. Without it, mobile browsers eat the pointer events. */
.sortable-icon { .sortable-icon {
display: none; display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
padding: 6px;
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
transition: background-color 0.15s ease, color 0.15s ease;
user-select: none;
touch-action: none;
} }
.sortable-icon:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
.sortable-icon:active { cursor: grabbing; }
.sortable-icon:focus-visible {
outline: 2px solid #008771;
outline-offset: 2px;
} }
.ant-table-is-sorting .draggable-row td { .light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
background-color: #ffffff !important; .light .sortable-icon:hover {
color: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.05);
} }
.dark .ant-table-is-sorting .draggable-row td { /* While dragging: the source row gets a soft green wash so the user can
background-color: var(--dark-color-surface-100) !important; track which row is being moved. Other rows transition smoothly as the
data-source is reordered. */
.sortable-table-dragging .sortable-source-row > td {
background: rgba(0, 135, 113, 0.10) !important;
transition: background-color 0.18s ease;
} }
.sortable-table-dragging .sortable-source-row .routing-index,
.ant-table-is-sorting .dragging td { .sortable-table-dragging .sortable-source-row .outbound-index {
background-color: rgb(232 244 242) !important; opacity: 0.45;
color: rgba(0, 0, 0, 0.3);
} }
.sortable-table-dragging .sortable-row > td {
.dark .ant-table-is-sorting .dragging td { transition: background-color 0.18s ease;
background-color: var(--dark-color-table-hover) !important;
color: rgba(255, 255, 255, 0.3);
} }
/* Disable text selection across the whole table while a drag is in
.ant-table-is-sorting .dragging { progress — selection during drag is never useful and looks broken. */
opacity: 1; .sortable-table-dragging,
box-shadow: 1px -2px 2px #008771; .sortable-table-dragging * {
transition: all 0.2s; user-select: none;
}
.ant-table-is-sorting .dragging .ant-table-row-index {
opacity: 0.3;
} }
</style> </style>
{{end}} {{end}}

View File

@@ -282,16 +282,15 @@
</a-dropdown> </a-dropdown>
</template> </template>
<template slot="protocol" slot-scope="text, dbInbound"> <template slot="protocol" slot-scope="text, dbInbound">
<a-tag :style="{ margin: '0' }" color="purple">[[ <div class="protocol-tags">
dbInbound.protocol ]]</a-tag> <a-tag color="purple">[[ dbInbound.protocol ]]</a-tag>
<template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS"> <template
<a-tag :style="{ margin: '0' }" color="green">[[ v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
dbInbound.toInbound().stream.network ]]</a-tag> <a-tag color="green">[[ dbInbound.toInbound().stream.network ]]</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isTls" <a-tag v-if="dbInbound.toInbound().stream.isTls" color="blue">TLS</a-tag>
color="blue">TLS</a-tag> <a-tag v-if="dbInbound.toInbound().stream.isReality" color="blue">Reality</a-tag>
<a-tag :style="{ margin: '0' }" v-if="dbInbound.toInbound().stream.isReality"
color="blue">Reality</a-tag>
</template> </template>
</div>
</template> </template>
<template slot="clients" slot-scope="text, dbInbound"> <template slot="clients" slot-scope="text, dbInbound">
<template v-if="clientCount[dbInbound.id]"> <template v-if="clientCount[dbInbound.id]">
@@ -644,8 +643,11 @@
</a-popover> </a-popover>
</template> </template>
<template slot="expandedRowRender" slot-scope="record"> <template slot="expandedRowRender" slot-scope="record">
<a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns" <a-table :row-key="client => client.id"
:data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record)) :columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)"
:pagination=pagination(getInboundClients(record))
:scroll="isMobile ? {} : { x: 'max-content' }"
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }"> :style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
{{template "component/aClientTable" .}} {{template "component/aClientTable" .}}
</a-table> </a-table>
@@ -986,58 +988,14 @@
}, },
}]; }];
const innerColumns = [{ const innerColumns = [
title: '{{ i18n "pages.inbounds.operate" }}', { title: '{{ i18n "pages.inbounds.operate" }}', width: 140, scopedSlots: { customRender: 'actions' } },
width: 70, { title: '{{ i18n "pages.inbounds.enable" }}', width: 60, scopedSlots: { customRender: 'enable' } },
scopedSlots: { { title: '{{ i18n "online" }}', width: 80, scopedSlots: { customRender: 'online' } },
customRender: 'actions' { title: '{{ i18n "pages.inbounds.client" }}', width: 160, scopedSlots: { customRender: 'client' } },
} { title: '{{ i18n "pages.inbounds.traffic" }}', width: 200, align: 'center', scopedSlots: { customRender: 'traffic' } },
}, { title: '{{ i18n "pages.inbounds.allTimeTraffic" }}', width: 110, align: 'center', scopedSlots: { customRender: 'allTime' } },
{ { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
title: '{{ i18n "pages.inbounds.enable" }}',
width: 30,
scopedSlots: {
customRender: 'enable'
}
},
{
title: '{{ i18n "online" }}',
width: 32,
scopedSlots: {
customRender: 'online'
}
},
{
title: '{{ i18n "pages.inbounds.client" }}',
width: 80,
scopedSlots: {
customRender: 'client'
}
},
{
title: '{{ i18n "pages.inbounds.traffic" }}',
width: 80,
align: 'center',
scopedSlots: {
customRender: 'traffic'
}
},
{
title: '{{ i18n "pages.inbounds.allTimeTraffic" }}',
width: 60,
align: 'center',
scopedSlots: {
customRender: 'allTime'
}
},
{
title: '{{ i18n "pages.inbounds.expireDate" }}',
width: 80,
align: 'center',
scopedSlots: {
customRender: 'expiryTime'
}
},
]; ];
const innerMobileColumns = [{ const innerMobileColumns = [{
@@ -1087,7 +1045,7 @@
trafficDiff: 0, trafficDiff: 0,
defaultCert: '', defaultCert: '',
defaultKey: '', defaultKey: '',
clientCount: [], clientCount: {},
onlineClients: [], onlineClients: [],
lastOnlineMap: {}, lastOnlineMap: {},
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false, isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
@@ -1111,6 +1069,71 @@
loading(spinning = true) { loading(spinning = true) {
this.loadingStates.spinning = spinning; this.loadingStates.spinning = spinning;
}, },
// applyClientStatsDelta updates client traffic counters and inbound totals
// in-place from a WebSocket delta payload. Avoids full-list re-fetch and
// re-render — critical at 10k+ client scale.
applyClientStatsDelta(payload) {
if (!payload || typeof payload !== 'object') return;
const inboundsById = new Map();
this.dbInbounds.forEach(ib => inboundsById.set(ib.id, ib));
const touched = new Set();
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
for (const stat of payload.clients) {
const dbInbound = inboundsById.get(stat.inboundId);
if (!dbInbound || !Array.isArray(dbInbound.clientStats)) continue;
const cs = this.getClientStats(dbInbound, stat.email);
if (!cs) continue;
cs.up = stat.up;
cs.down = stat.down;
// allTime is the cumulative-historical counter shown in the
// "Общий трафик" column. The previous handler updated up/down/
// total but skipped allTime, so that column stayed frozen at
// its initial-page-load value until a manual refresh.
if (stat.allTime !== undefined) cs.allTime = stat.allTime;
if (stat.total !== undefined) cs.total = stat.total;
if (stat.expiryTime !== undefined) cs.expiryTime = stat.expiryTime;
if (stat.lastOnline !== undefined) cs.lastOnline = stat.lastOnline;
if (stat.enable !== undefined) cs.enable = stat.enable;
touched.add(stat.inboundId);
}
}
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
for (const summary of payload.inbounds) {
const dbInbound = inboundsById.get(summary.id);
if (!dbInbound) continue;
dbInbound.up = summary.up;
dbInbound.down = summary.down;
if (summary.total !== undefined) dbInbound.total = summary.total;
if (summary.allTime !== undefined) dbInbound.allTime = summary.allTime;
if (summary.enable !== undefined) dbInbound.enable = summary.enable;
}
}
// Recompute clientCount for inbounds whose stats changed. The cached
// parsed Inbound is fetched via dbInbound.toInbound() — earlier
// versions used `this.inbounds.find(ib => ib.id === id)` which
// ALWAYS returned undefined (the Inbound class has no id field), so
// this branch silently never ran and depleted/expiring/online filters
// never refreshed from delta updates.
if (touched.size > 0) {
for (const id of touched) {
const dbInbound = inboundsById.get(id);
if (dbInbound) {
this.$set(this.clientCount, id, this.getClientCounts(dbInbound, dbInbound.toInbound()));
}
}
}
// Re-run filter/search so the displayed slice picks up updated values.
if (this.enableFilter) {
this.filterInbounds();
} else {
this.searchInbounds(this.searchKey);
}
},
async getDBInbounds() { async getDBInbounds() {
this.refreshing = true; this.refreshing = true;
const msg = await HttpUtil.get('/panel/api/inbounds/list'); const msg = await HttpUtil.get('/panel/api/inbounds/list');
@@ -1165,7 +1188,11 @@
setInbounds(dbInbounds) { setInbounds(dbInbounds) {
this.inbounds.splice(0); this.inbounds.splice(0);
this.dbInbounds.splice(0); this.dbInbounds.splice(0);
this.clientCount.splice(0); // Drop every existing key — Vue.delete keeps it reactive so any
// template expression watching clientCount[id] re-renders cleanly.
for (const key of Object.keys(this.clientCount)) {
this.$delete(this.clientCount, key);
}
for (const inbound of dbInbounds) { for (const inbound of dbInbounds) {
const dbInbound = new DBInbound(inbound); const dbInbound = new DBInbound(inbound);
to_inbound = dbInbound.toInbound() to_inbound = dbInbound.toInbound()
@@ -1176,7 +1203,9 @@
if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) { if (dbInbound.isSS && (!to_inbound.isSSMultiUser)) {
continue; continue;
} }
this.clientCount[inbound.id] = this.getClientCounts(inbound, to_inbound); // Reactive add — direct assignment on the map would not trigger
// template updates in Vue 2.
this.$set(this.clientCount, inbound.id, this.getClientCounts(inbound, to_inbound));
} }
} }
if (!this.loadingStates.fetched) { if (!this.loadingStates.fetched) {
@@ -1681,39 +1710,29 @@
newDbInbound = this.checkFallback(dbInbound); newDbInbound = this.checkFallback(dbInbound);
infoModal.show(newDbInbound, index); infoModal.show(newDbInbound, index);
}, },
switchEnable(dbInboundId, state) { // switchEnable toggles inbound.enable through a dedicated lightweight
let dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); // endpoint. The previous implementation re-submitted the entire
// inbound settings JSON (every client) just to flip a boolean — on a
// 7000+ client inbound that meant a multi-MB request, an O(N) traffic
// diff and a full xray-config rebuild for every click of the switch.
async switchEnable(dbInboundId, state) {
const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
if (!dbInbound) return; if (!dbInbound) return;
dbInbound.enable = state;
let inbound = dbInbound.toInbound();
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: inbound.listen, const previous = dbInbound.enable;
port: inbound.port, dbInbound.enable = state; // optimistic: UI reflects the click immediately
protocol: inbound.protocol,
settings: inbound.settings.toString(),
};
if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({
sockopt: inbound.stream.sockopt.toJson()
}, null, 2);
}
data.sniffing = inbound.sniffing.toString();
const formData = new FormData(); const formData = new FormData();
Object.keys(data).forEach(key => formData.append(key, data[key])); formData.append('enable', String(state));
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, formData); try {
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInboundId}`, formData);
if (!msg || !msg.success) {
dbInbound.enable = previous;
}
} catch (e) {
dbInbound.enable = previous;
}
}, },
async switchEnableClient(dbInboundId, client, state) { async switchEnableClient(dbInboundId, client, state) {
this.loading(); this.loading();
@@ -1796,15 +1815,18 @@
isExpiry(dbInbound, index) { isExpiry(dbInbound, index) {
return dbInbound.toInbound().isExpiry(index); return dbInbound.toInbound().isExpiry(index);
}, },
// getClientStats returns the cached email→clientStat lookup for an
// inbound, building it lazily. The cache is invalidated when the
// underlying clientStats array reference changes (full re-fetch),
// so delta updates and post-refetch lookups never see stale entries.
// This is the single source of truth — applyClientStatsDelta uses it too.
getClientStats(dbInbound, email) { getClientStats(dbInbound, email) {
if (!dbInbound) return null; if (!dbInbound || !Array.isArray(dbInbound.clientStats)) return null;
if (!dbInbound._clientStatsMap) { if (!dbInbound._clientStatsMap || dbInbound._clientStatsMapSrc !== dbInbound.clientStats) {
dbInbound._clientStatsMap = new Map(); const map = new Map();
if (dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) { for (const cs of dbInbound.clientStats) map.set(cs.email, cs);
for (const stats of dbInbound.clientStats) { dbInbound._clientStatsMap = map;
dbInbound._clientStatsMap.set(stats.email, stats); dbInbound._clientStatsMapSrc = dbInbound.clientStats;
}
}
} }
return dbInbound._clientStatsMap.get(email); return dbInbound._clientStatsMap.get(email);
}, },
@@ -1825,9 +1847,15 @@
}, },
getAllTimeClient(dbInbound, email) { getAllTimeClient(dbInbound, email) {
if (!email || email.length == 0) return 0; if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email); const clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0; if (!clientStats) return 0;
return clientStats.allTime || (clientStats.up + clientStats.down); // allTime represents cumulative historical usage and must never
// appear smaller than the currently-tracked counters. If a stale
// row drifts below up+down (manual edits, partial migrations) we
// surface the live total instead of the misleading historical one.
const current = (clientStats.up || 0) + (clientStats.down || 0);
const allTime = clientStats.allTime || 0;
return allTime > current ? allTime : current;
}, },
getRemStats(dbInbound, email) { getRemStats(dbInbound, email) {
if (!email || email.length == 0) return 0; if (!email || email.length == 0) return 0;
@@ -2039,13 +2067,18 @@
this.loading(); this.loading();
this.getDefaultSettings(); this.getDefaultSettings();
// Initial data fetch // Bootstrap from REST first, then attach WebSocket subscriptions.
// Doing this in order eliminates a race where an early `inbounds` push
// fires getClientCounts() before this.onlineClients is populated,
// leaving online[] empty for every inbound and breaking the filter.
this.getDBInbounds().then(() => { this.getDBInbounds().then(() => {
this.loading(false); this.loading(false);
});
// Setup WebSocket for real-time updates if (!window.wsClient) {
if (window.wsClient) { // Fallback to polling if WebSocket is not available
if (this.isRefreshEnabled) this.startDataRefreshLoop();
return;
}
window.wsClient.connect(); window.wsClient.connect();
// Listen for inbounds updates // Listen for inbounds updates
@@ -2056,12 +2089,13 @@
} }
}); });
// Listen for invalidate signals (sent when payload is too large for WebSocket) // Listen for invalidate signals — last-resort safety only.
// The server sends a lightweight notification and we re-fetch via REST API // Under normal operation the server pushes 'client_stats' deltas
// instead, so this fires only when an admin mutation produces an
// oversized full-list payload.
let invalidateTimer = null; let invalidateTimer = null;
window.wsClient.on('invalidate', (payload) => { window.wsClient.on('invalidate', (payload) => {
if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) { if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
// Debounce to avoid flooding the REST API with multiple invalidate signals
if (invalidateTimer) clearTimeout(invalidateTimer); if (invalidateTimer) clearTimeout(invalidateTimer);
invalidateTimer = setTimeout(() => { invalidateTimer = setTimeout(() => {
invalidateTimer = null; invalidateTimer = null;
@@ -2070,15 +2104,36 @@
} }
}); });
// Listen for traffic updates // Real-time delta updates: per-client absolute counters + inbound
window.wsClient.on('traffic', (payload) => { // totals applied in-place. Replaces the periodic full-list refresh
// Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event // and scales to 10k+ clients without REST fallback.
// because clientTraffics contains delta/incremental values, not total accumulated values. window.wsClient.on('client_stats', (payload) => {
// Total traffic is updated via the 'inbounds' WebSocket event (or 'invalidate' fallback for large panels). if (!payload) return;
this.applyClientStatsDelta(payload);
});
// Update online clients list in real-time // Listen for traffic updates.
if (payload && Array.isArray(payload.onlineClients)) { // Note: clientTraffics contains DELTA values (incremental since last
const nextOnlineClients = payload.onlineClients; // tick), not absolute totals. Absolute counters are updated through
// the 'client_stats' event in applyClientStatsDelta.
window.wsClient.on('traffic', (payload) => {
if (!payload || typeof payload !== 'object') return;
// Normalize onlineClients: server marshals a nil []string slice as
// JSON null when nobody is online. Treat null/undefined/missing as
// an empty array so the "everyone went offline" transition still
// updates the UI — without this fix, the last set of online users
// stayed visible (and the online filter kept showing them) until
// someone came back online.
const hasOnlinePayload =
'onlineClients' in payload &&
(Array.isArray(payload.onlineClients) || payload.onlineClients == null);
if (hasOnlinePayload) {
const nextOnlineClients = Array.isArray(payload.onlineClients)
? payload.onlineClients
: [];
// Detect change in either direction: length differs OR sets differ.
let onlineChanged = this.onlineClients.length !== nextOnlineClients.length; let onlineChanged = this.onlineClients.length !== nextOnlineClients.length;
if (!onlineChanged) { if (!onlineChanged) {
const prevSet = new Set(this.onlineClients); const prevSet = new Set(this.onlineClients);
@@ -2089,18 +2144,24 @@
} }
} }
} }
this.onlineClients = nextOnlineClients; this.onlineClients = nextOnlineClients;
if (onlineChanged) { if (onlineChanged) {
// Recalculate client counts to update online status // Recompute clientCount for every inbound whose stats can host
// Use $set for Vue 2 reactivity — direct array index assignment is not reactive // online clients. `dbInbound.toInbound()` returns the cached
// parsed Inbound (with the .clients array) — using it directly
// avoids a brittle `this.inbounds.find(ib => ib.id === ...)`
// lookup that ALWAYS failed because the Inbound class has no
// `id` field. That silent failure was the real cause of the
// online filter showing an empty list while a client was
// clearly online elsewhere on the page.
this.dbInbounds.forEach(dbInbound => { this.dbInbounds.forEach(dbInbound => {
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id); const inbound = dbInbound.toInbound();
if (inbound && this.clientCount[dbInbound.id]) {
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound)); this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
}
}); });
// Always trigger UI refresh — not just when filter is enabled // Re-run filter/search so the UI reflects the new state — both
// when clients come online and when they go offline.
if (this.enableFilter) { if (this.enableFilter) {
this.filterInbounds(); this.filterInbounds();
} else { } else {
@@ -2109,9 +2170,9 @@
} }
} }
// Update last online map in real-time // Update last-online map. Server sends the full map (not delta) so
// Replace entirely (server sends the full map) to avoid unbounded growth from deleted clients // we can replace entirely without growing unbounded from deleted clients.
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') { if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
this.lastOnlineMap = payload.lastOnlineMap; this.lastOnlineMap = payload.lastOnlineMap;
} }
}); });
@@ -2132,12 +2193,7 @@
} }
} }
}); });
} else { });
// Fallback to polling if WebSocket is not available
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
}
}, },
computed: { computed: {
total() { total() {
@@ -2186,5 +2242,89 @@
left: 50vw !important; left: 50vw !important;
} }
} }
/* Protocol cell — wrap tags into a flex grid with consistent gap so
vless/xhttp/Reality line up cleanly instead of stacking awkwardly. */
.inbounds-page .protocol-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
max-width: 100%;
}
.inbounds-page .protocol-tags .ant-tag {
margin: 0;
line-height: 20px;
}
/* Traffic / expiry cell — flex layout:
- Side text (.tr-table-rt / .tr-table-lt) sizes to content.
- Progress bar (.tr-table-bar) takes whatever's left and is allowed to
shrink (min-width: 0) so the cell never visually overflows its column,
no matter how long the surrounding values are ("999.99 GB", "365d").
A flex <div> replaces the previous <table>/<tr>/<td> hack — table layout
ignored width: 100% on the row, so the row grew to its content width and
pushed past the a-table column boundary. */
.inbounds-page .tr-table-box {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 2px 10px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.04);
}
/* Fixed widths so the bar starts/ends at the same X position across all
rows — without this, "126.45 MB" and "0 B" pushed the bar to different
spots, which read as misalignment in the column. */
.inbounds-page .tr-table-rt,
.inbounds-page .tr-table-lt {
flex: 0 0 auto;
white-space: nowrap;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
}
.inbounds-page .tr-table-rt {
text-align: end;
flex-basis: 70px;
min-width: 70px;
}
.inbounds-page .tr-table-lt {
text-align: start;
flex-basis: 28px;
min-width: 28px;
}
.inbounds-page .tr-table-bar {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
display: block;
}
/* Make the progress widget fill its flex cell, and align the inner fill
pill with the outer track pill (the "two pills" drift was caused by
box-sizing: content-box plus a 1px border on .ant-progress-bg). */
.inbounds-page .tr-table-bar .ant-progress,
.inbounds-page .tr-table-bar .ant-progress-outer,
.inbounds-page .tr-table-bar .ant-progress-inner {
display: block;
width: 100%;
margin: 0;
padding: 0;
}
.inbounds-page .infinite-bar .ant-progress-inner,
.inbounds-page .tr-table-bar .ant-progress-inner {
box-sizing: border-box;
border-radius: 100px;
overflow: hidden;
}
.inbounds-page .infinite-bar .ant-progress-inner .ant-progress-bg,
.inbounds-page .tr-table-bar .ant-progress-inner .ant-progress-bg {
box-sizing: border-box;
border: 0 !important;
}
</style> </style>
{{ template "page/body_end" .}} {{ template "page/body_end" .}}

View File

@@ -1,8 +1,8 @@
{{define "settings/xray/outbounds"}} {{define "settings/xray/outbounds"}}
<a-space direction="vertical" size="middle"> <a-space direction="vertical" size="middle" class="outbounds-modern">
<a-row> <a-row :gutter="[12, 12]" align="middle" justify="space-between">
<a-col :xs="12" :sm="12" :lg="12"> <a-col :xs="24" :sm="14" :lg="14">
<a-space direction="horizontal" size="small"> <a-space direction="horizontal" size="small" class="outbounds-toolbar">
<a-button type="primary" icon="plus" @click="addOutbound"> <a-button type="primary" icon="plus" @click="addOutbound">
<span v-if="!isMobile">{{ i18n <span v-if="!isMobile">{{ i18n
"pages.xray.outbound.addOutbound" }}</span> "pages.xray.outbound.addOutbound" }}</span>
@@ -11,7 +11,7 @@
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button> <a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
</a-space> </a-space>
</a-col> </a-col>
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }"> <a-col :xs="24" :sm="10" :lg="10" class="outbounds-toolbar-right">
<a-button-group> <a-button-group>
<a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button> <a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button>
<a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)" <a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)"
@@ -19,22 +19,30 @@
:overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}'
cancel-text='{{ i18n "cancel"}}'> cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" <a-icon slot="icon" type="question-circle-o"
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon> :style="{ color: '#008771' }"></a-icon>
<a-button icon="retweet"></a-button> <a-button icon="retweet"></a-button>
</a-popconfirm> </a-popconfirm>
</a-button-group> </a-button-group>
</a-col> </a-col>
</a-row> </a-row>
<a-table :columns="outboundColumns" bordered :row-key="r => r.key" :data-source="outboundData" <a-table :columns="outboundColumns" :row-key="r => r.key"
:scroll="isMobile ? {} : { x: 800 }" :pagination="false" :indent-size="0" :data-source="outboundData"
:scroll="isMobile ? { x: 720 } : {}"
:pagination="false"
:indent-size="0"
class="outbounds-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'> :locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
<template slot="action" slot-scope="text, outbound, index"> <template slot="action" slot-scope="text, outbound, index">
<span>[[ index+1 ]]</span> <div class="outbound-action-cell">
<span class="outbound-index">[[ index+1 ]]</span>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more" <a-button shape="circle" size="small" class="outbound-action-btn"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon> @click="e => e.preventDefault()">
<a-icon type="more"></a-icon>
</a-button>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme"> <a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0" @click="setFirstOutbound(index)"> <a-menu-item v-if="index>0"
@click="setFirstOutbound(index)">
<a-icon type="vertical-align-top"></a-icon> <a-icon type="vertical-align-top"></a-icon>
<span>{{ i18n "pages.xray.rules.first"}}</span> <span>{{ i18n "pages.xray.rules.first"}}</span>
</a-menu-item> </a-menu-item>
@@ -43,10 +51,8 @@
<span>{{ i18n "edit" }}</span> <span>{{ i18n "edit" }}</span>
</a-menu-item> </a-menu-item>
<a-menu-item @click="resetOutboundTraffic(index)"> <a-menu-item @click="resetOutboundTraffic(index)">
<span>
<a-icon type="retweet"></a-icon> <a-icon type="retweet"></a-icon>
<span>{{ i18n "pages.inbounds.resetTraffic"}}</span> <span>{{ i18n "pages.inbounds.resetTraffic"}}</span>
</span>
</a-menu-item> </a-menu-item>
<a-menu-item @click="deleteOutbound(index)"> <a-menu-item @click="deleteOutbound(index)">
<span :style="{ color: '#FF4D4F' }"> <span :style="{ color: '#FF4D4F' }">
@@ -56,54 +62,97 @@
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</div>
</template> </template>
<template slot="address" slot-scope="text, outbound, index"> <template slot="identity" slot-scope="text, outbound">
<p :style="{ margin: '0 5px' }" v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p> <div class="outbound-identity-cell">
</template> <a-tooltip :title="outbound.tag" :overlay-class-name="themeSwitcher.currentTheme">
<template slot="protocol" slot-scope="text, outbound, index"> <span class="outbound-tag">[[ outbound.tag ]]</span>
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol </a-tooltip>
]]</a-tag> <div class="outbound-protocol-cell">
<span class="outbound-pill"
:class="outboundProtocolTone(outbound.protocol)">
[[ outbound.protocol ]]
</span>
<template <template
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)"> v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
<a-tag :style="{ margin: '0' }" color="blue">[[ <span class="outbound-pill"
outbound.streamSettings.network ]]</a-tag> :class="outboundNetworkTone(outbound.streamSettings.network)">
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='tls'" color="green">tls</a-tag> [[ outbound.streamSettings.network ]]
<a-tag :style="{ margin: '0' }" v-if="outbound.streamSettings.security=='reality'" </span>
color="green">reality</a-tag> <span class="outbound-pill"
:class="outboundSecurityTone(outbound.streamSettings.security)"
v-if="isOutboundSecurityVisible(outbound.streamSettings.security)">
[[ outbound.streamSettings.security ]]
</span>
</template> </template>
</div>
</div>
</template> </template>
<template slot="traffic" slot-scope="text, outbound, index"> <template slot="address" slot-scope="text, outbound">
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag> <div class="outbound-address-list">
<a-tooltip
v-for="addr in outboundAddresses(outbound)"
:key="addr"
:title="addr"
:overlay-class-name="themeSwitcher.currentTheme">
<span class="outbound-address-pill">[[ addr ]]</span>
</a-tooltip>
<span class="outbound-address-empty"
v-if="outboundAddresses(outbound).length === 0"></span>
</div>
</template>
<template slot="traffic" slot-scope="text, outbound">
<div class="outbound-traffic-cell">
<span class="outbound-traffic-up" :title='`{{ i18n "pages.index.upload" }}`'>
<a-icon type="arrow-up"></a-icon>
[[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
</span>
<span class="outbound-traffic-sep" aria-hidden="true"></span>
<span class="outbound-traffic-down" :title='`{{ i18n "pages.index.download" }}`'>
<a-icon type="arrow-down"></a-icon>
[[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
</span>
</div>
</template> </template>
<template slot="test" slot-scope="text, outbound, index"> <template slot="test" slot-scope="text, outbound, index">
<a-tooltip> <a-tooltip>
<template slot="title">{{ i18n "pages.xray.outbound.test" <template slot="title">{{ i18n "pages.xray.outbound.test" }}</template>
}}</template> <a-button
<a-button type="primary" shape="circle" icon="thunderbolt" type="primary"
:loading="outboundTestStates[index] && outboundTestStates[index].testing" shape="circle"
icon="thunderbolt"
class="outbound-test-btn"
:loading="isOutboundTesting(index)"
@click="testOutbound(index)" @click="testOutbound(index)"
:disabled="(outbound.protocol === 'blackhole' || outbound.tag === 'blocked') || (outboundTestStates[index] && outboundTestStates[index].testing)"> :disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
</a-button> </a-button>
</a-tooltip> </a-tooltip>
</template> </template>
<template slot="testResult" slot-scope="text, outbound, index"> <template slot="testResult" slot-scope="text, outbound, index">
<div v-if="outboundTestStates[index] && outboundTestStates[index].result"> <div class="outbound-result-cell" v-if="outboundTestResult(index)">
<a-tag v-if="outboundTestStates[index].result.success" color="green"> <span v-if="outboundTestResult(index).success"
[[ outboundTestStates[index].result.delay ]]ms class="outbound-result-pill outbound-result-ok">
<span v-if="outboundTestStates[index].result.statusCode"> <a-icon type="check-circle" theme="filled"></a-icon>
([[ outboundTestStates[index].result.statusCode [[ outboundTestResult(index).delay ]]&nbsp;ms
]])</span> <span class="outbound-result-status"
</a-tag> v-if="outboundTestResult(index).statusCode">
<a-tooltip v-else :title="outboundTestStates[index].result.error"> · [[ outboundTestResult(index).statusCode ]]
<a-tag color="red"> </span>
Failed </span>
</a-tag> <a-tooltip v-else
:title="outboundTestResult(index).error"
:overlay-class-name="themeSwitcher.currentTheme">
<span class="outbound-result-pill outbound-result-fail">
<a-icon type="close-circle" theme="filled"></a-icon>
{{ i18n "pages.xray.outbound.testFailed" }}
</span>
</a-tooltip> </a-tooltip>
</div> </div>
<span v-else-if="outboundTestStates[index] && outboundTestStates[index].testing"> <span class="outbound-result-loading" v-else-if="isOutboundTesting(index)">
<a-icon type="loading" /> <a-icon type="loading"></a-icon>
</span> </span>
<span v-else>-</span> <span class="outbound-result-idle" v-else></span>
</template> </template>
</a-table> </a-table>
</a-space> </a-space>

View File

@@ -1,123 +1,193 @@
{{define "settings/xray/routing"}} {{define "settings/xray/routing"}}
<a-space direction="vertical" size="middle"> <a-space direction="vertical" size="middle" class="routing-modern">
<a-button type="primary" icon="plus" @click="addRule">{{ i18n "pages.xray.rules.add" }}</a-button> <a-button type="primary" icon="plus" @click="addRule">{{ i18n
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns" bordered :row-key="r => r.key" "pages.xray.rules.add" }}</a-button>
:data-source="routingRuleData" :scroll="isMobile ? {} : { x: 1000 }" :pagination="false" :indent-size="0" <a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns"
:row-key="r => r.key"
:data-source="routingRuleData"
:scroll="{}"
:pagination="false"
:indent-size="0"
class="routing-table"
v-on:onSort="replaceRule"> v-on:onSort="replaceRule">
<template slot="action" slot-scope="text, rule, index"> <template slot="action" slot-scope="text, rule, index">
<div class="routing-action-cell">
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger> <a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
<span class="ant-table-row-index"> [[ index+1 ]] </span> <span class="routing-index">[[ index+1 ]]</span>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more" <a-button shape="circle" size="small" class="routing-action-btn"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon> @click="e => e.preventDefault()">
<a-icon type="more"></a-icon>
</a-button>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme"> <a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0" @click="replaceRule(index,0)">
<a-icon type="vertical-align-top"></a-icon>
{{ i18n "pages.xray.rules.first"}}
</a-menu-item>
<a-menu-item v-if="index>0" @click="replaceRule(index,index-1)">
<a-icon type="arrow-up"></a-icon>
{{ i18n "pages.xray.rules.up"}}
</a-menu-item>
<a-menu-item v-if="index<routingRuleData.length-1" @click="replaceRule(index,index+1)">
<a-icon type="arrow-down"></a-icon>
{{ i18n "pages.xray.rules.down"}}
</a-menu-item>
<a-menu-item v-if="index<routingRuleData.length-1"
@click="replaceRule(index,routingRuleData.length-1)">
<a-icon type="vertical-align-bottom"></a-icon>
{{ i18n "pages.xray.rules.last"}}
</a-menu-item>
<a-menu-item @click="editRule(index)"> <a-menu-item @click="editRule(index)">
<a-icon type="edit"></a-icon> <a-icon type="edit"></a-icon>
{{ i18n "edit" }} <span>{{ i18n "edit" }}</span>
</a-menu-item> </a-menu-item>
<a-menu-item @click="deleteRule(index)"> <a-menu-item @click="deleteRule(index)">
<span :style="{ color: '#FF4D4F' }"> <span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon> {{ i18n "delete"}} <a-icon type="delete"></a-icon>
<span>{{ i18n "delete" }}</span>
</span> </span>
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</div>
</template> </template>
<template slot="inbound" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme"> <template slot="source" slot-scope="text, rule">
<template slot="content"> <div class="criterion-flow">
<p v-if="rule.inboundTag">Inbound Tag: [[ rule.inboundTag ]]</p> <a-tooltip v-if="rule.sourceIP"
<p v-if="rule.user">User email: [[ rule.user ]]</p> :title="'Source IP: ' + joinCsv(rule.sourceIP)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">[[ csv(rule.sourceIP)[0] ]]</span>
<span v-if="csv(rule.sourceIP).length > 1" class="criterion-more">+[[ csv(rule.sourceIP).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.sourcePort"
:title="'Source Port: ' + joinCsv(rule.sourcePort)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">[[ csv(rule.sourcePort)[0] ]]</span>
<span v-if="csv(rule.sourcePort).length > 1" class="criterion-more">+[[ csv(rule.sourcePort).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.vlessRoute"
:title="'VLESS Route: ' + joinCsv(rule.vlessRoute)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">VLESS</span>
<span class="criterion-value">[[ csv(rule.vlessRoute)[0] ]]</span>
<span v-if="csv(rule.vlessRoute).length > 1" class="criterion-more">+[[ csv(rule.vlessRoute).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.sourceIP && !rule.sourcePort && !rule.vlessRoute"></span>
</div>
</template> </template>
[[ [rule.inboundTag,rule.user].join('\n') ]]
</a-popover> <template slot="network" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.network"
:title="'L4: ' + joinCsv(rule.network)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">L4</span>
<span class="criterion-value">[[ csv(rule.network)[0] ]]</span>
<span v-if="csv(rule.network).length > 1" class="criterion-more">+[[ csv(rule.network).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.protocol"
:title="'Protocol: ' + joinCsv(rule.protocol)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Protocol</span>
<span class="criterion-value">[[ csv(rule.protocol)[0] ]]</span>
<span v-if="csv(rule.protocol).length > 1" class="criterion-more">+[[ csv(rule.protocol).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.attrs"
:title="'Attrs: ' + joinCsv(rule.attrs)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Attrs</span>
<span class="criterion-value">[[ csv(rule.attrs)[0] ]]</span>
<span v-if="csv(rule.attrs).length > 1" class="criterion-more">+[[ csv(rule.attrs).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.network && !rule.protocol && !rule.attrs"></span>
</div>
</template> </template>
<template slot="outbound" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme"> <template slot="destination" slot-scope="text, rule">
<template slot="content"> <div class="criterion-flow">
<p v-if="rule.outboundTag">Outbound Tag: [[ rule.outboundTag ]]</p> <a-tooltip v-if="rule.ip"
:title="'Destination IP: ' + joinCsv(rule.ip)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">[[ csv(rule.ip)[0] ]]</span>
<span v-if="csv(rule.ip).length > 1" class="criterion-more">+[[ csv(rule.ip).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.domain"
:title="'Domain: ' + joinCsv(rule.domain)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Domain</span>
<span class="criterion-value">[[ csv(rule.domain)[0] ]]</span>
<span v-if="csv(rule.domain).length > 1" class="criterion-more">+[[ csv(rule.domain).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.port"
:title="'Destination Port: ' + joinCsv(rule.port)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">[[ csv(rule.port)[0] ]]</span>
<span v-if="csv(rule.port).length > 1" class="criterion-more">+[[ csv(rule.port).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.ip && !rule.domain && !rule.port"></span>
</div>
</template> </template>
[[ rule.outboundTag ]]
</a-popover> <template slot="inbound" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.inboundTag"
:title="'Inbound Tag: ' + joinCsv(rule.inboundTag)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Tag</span>
<span class="criterion-value">[[ csv(rule.inboundTag)[0] ]]</span>
<span v-if="csv(rule.inboundTag).length > 1" class="criterion-more">+[[ csv(rule.inboundTag).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.user"
:title="'Client: ' + joinCsv(rule.user)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">User</span>
<span class="criterion-value">[[ csv(rule.user)[0] ]]</span>
<span v-if="csv(rule.user).length > 1" class="criterion-more">+[[ csv(rule.user).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.inboundTag && !rule.user"></span>
</div>
</template> </template>
<template slot="balancer" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme"> <template slot="target" slot-scope="text, rule">
<template slot="content"> <div class="routing-target-cell">
<p v-if="rule.balancerTag">Balancer Tag: [[ rule.balancerTag ]]</p> <div class="routing-target-row" v-if="rule.outboundTag">
</template> <a-icon type="export" class="routing-target-icon"></a-icon>
[[ rule.balancerTag ]] <span class="outbound-pill tone-emerald">[[ rule.outboundTag ]]</span>
</a-popover> </div>
</template> <div class="routing-target-row" v-if="rule.balancerTag">
<template slot="info" slot-scope="text, rule, index"> <a-icon type="cluster" class="routing-target-icon"></a-icon>
<a-popover placement="bottomRight" <span class="outbound-pill tone-violet">[[ rule.balancerTag ]]</span>
v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0" </div>
:overlay-class-name="themeSwitcher.currentTheme" trigger="click"> <span class="routing-criteria-empty"
<template slot="content"> v-if="!rule.outboundTag && !rule.balancerTag"></span>
<table cellpadding="2" :style="{ maxWidth: '300px' }"> </div>
<tr v-if="rule.sourceIP">
<td>Source IP</td>
<td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.sourcePort">
<td>Source Port</td>
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.vlessRoute">
<td>VLESS Route</td>
<td><a-tag color="geekblue" v-for="r in rule.vlessRoute.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.network">
<td>Network</td>
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.protocol">
<td>Protocol</td>
<td><a-tag color="green" v-for="r in rule.protocol.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.attrs">
<td>Attrs</td>
<td><a-tag color="blue" v-for="r in rule.attrs.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.ip">
<td>IP</td>
<td><a-tag color="green" v-for="r in rule.ip.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.domain">
<td>Domain</td>
<td><a-tag color="blue" v-for="r in rule.domain.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.port">
<td>Port</td>
<td><a-tag color="green" v-for="r in rule.port.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.balancerTag">
<td>Balancer Tag</td>
<td><a-tag color="blue">[[ rule.balancerTag ]]</a-tag></td>
</tr>
</table>
</template>
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
<a-icon type="info"></a-icon>
</a-button>
</a-popover>
</template> </template>
</a-table-sortable> </a-table-sortable>
</a-space> </a-space>
{{end}} {{end}}

View File

@@ -143,211 +143,45 @@
{{template "modals/warpModal" .}} {{template "modals/warpModal" .}}
{{template "modals/nordModal" .}} {{template "modals/nordModal" .}}
<script> <script>
const rulesColumns = [{ // Modernised rules layout — 6 cells (#, source, network, destination,
title: "#", // inbound, target). Each criterion renders as a single self-labelled
align: 'center', // pill that shows the first value plus a "+N" remainder badge for the
width: 15, // rest; the full list is surfaced via tooltip on hover. The destination
scopedSlots: { // column has no fixed width and absorbs leftover horizontal space so the
customRender: 'action' // table fits typical viewports without a horizontal scrollbar.
} const rulesColumns = [
}, { title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
{ { title: '{{ i18n "pages.xray.rules.source"}}', align: 'left', width: 180, scopedSlots: { customRender: 'source' } },
title: '{{ i18n "pages.xray.rules.source"}}', { title: '{{ i18n "pages.inbounds.network"}}', align: 'left', width: 180, scopedSlots: { customRender: 'network' } },
children: [{ { title: '{{ i18n "pages.xray.rules.dest"}}', align: 'left', scopedSlots: { customRender: 'destination' } },
title: 'IP', { title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', width: 180, scopedSlots: { customRender: 'inbound' } },
dataIndex: "sourceIP", { title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 170, scopedSlots: { customRender: 'target' } },
align: 'center',
width: 20,
ellipsis: true
},
{
title: '{{ i18n "pages.inbounds.port" }}',
dataIndex: 'sourcePort',
align: 'center',
width: 10,
ellipsis: true
},
{
title: 'VLESS Route',
dataIndex: 'vlessRoute',
align: 'center',
width: 15,
ellipsis: true
}
]
},
{
title: '{{ i18n "pages.inbounds.network"}}',
children: [{
title: 'L4',
dataIndex: 'network',
align: 'center',
width: 10
},
{
title: '{{ i18n "protocol" }}',
dataIndex: 'protocol',
align: 'center',
width: 15,
ellipsis: true
},
{
title: 'Attrs',
dataIndex: 'attrs',
align: 'center',
width: 10,
ellipsis: true
}
]
},
{
title: '{{ i18n "pages.xray.rules.dest"}}',
children: [{
title: 'IP',
dataIndex: 'ip',
align: 'center',
width: 20,
ellipsis: true
},
{
title: '{{ i18n "pages.xray.outbound.domain" }}',
dataIndex: 'domain',
align: 'center',
width: 20,
ellipsis: true
},
{
title: '{{ i18n "pages.inbounds.port" }}',
dataIndex: 'port',
align: 'center',
width: 10,
ellipsis: true
}
]
},
{
title: '{{ i18n "pages.xray.rules.inbound"}}',
children: [{
title: '{{ i18n "pages.xray.outbound.tag" }}',
dataIndex: 'inboundTag',
align: 'center',
width: 15,
ellipsis: true
},
{
title: '{{ i18n "pages.inbounds.client" }}',
dataIndex: 'user',
align: 'center',
width: 20,
ellipsis: true
}
]
},
{
title: '{{ i18n "pages.xray.rules.outbound"}}',
dataIndex: 'outboundTag',
align: 'center',
width: 17
},
{
title: '{{ i18n "pages.xray.rules.balancer"}}',
dataIndex: 'balancerTag',
align: 'center',
width: 15
},
]; ];
const rulesMobileColumns = [{ // Mobile: 3-column table — #, Inbound, Outbound. Source / Network /
title: "#", // Destination criteria are dropped to keep the table readable on
align: 'center', // narrow viewports. Users see the rule's identity (Inbound) and
width: 20, // what it does (Outbound) at a glance; full criteria are accessible
scopedSlots: { // by tapping Edit in the actions menu.
customRender: 'action' // # column is wider than desktop (110 vs 70) to fit the touch-friendly
} // drag handle (padding: 6px → ~28px) alongside the index and dropdown.
}, const rulesMobileColumns = [
{ { title: '#', align: 'center', width: 110, scopedSlots: { customRender: 'action' } },
title: '{{ i18n "pages.xray.rules.inbound"}}', { title: '{{ i18n "pages.xray.rules.inbound"}}', align: 'left', scopedSlots: { customRender: 'inbound' } },
align: 'center', { title: '{{ i18n "pages.xray.rules.outbound"}}', align: 'left', width: 140, scopedSlots: { customRender: 'target' } },
width: 50,
ellipsis: true,
scopedSlots: {
customRender: 'inbound'
}
},
{
title: '{{ i18n "pages.xray.rules.outbound"}}',
align: 'center',
width: 50,
ellipsis: true,
scopedSlots: {
customRender: 'outbound'
}
},
{
title: '{{ i18n "pages.xray.rules.info"}}',
align: 'center',
width: 50,
ellipsis: true,
scopedSlots: {
customRender: 'info'
}
},
]; ];
const outboundColumns = [{ const outboundColumns = [
title: "#", { title: '#', align: 'center', width: 70, scopedSlots: { customRender: 'action' } },
align: 'center', // Combined "Tag / Protocol" — saves a column. Tag stays on top, protocol +
width: 60, // network + security pills sit underneath it. Width chosen so the three
scopedSlots: { // longest tonal pills (e.g. vless + httpupgrade + reality) fit on a
customRender: 'action' // single line without wrapping.
} { title: '{{ i18n "pages.xray.outbound.tag"}}', align: 'left', width: 280, scopedSlots: { customRender: 'identity' } },
}, { title: '{{ i18n "pages.xray.outbound.address"}}', align: 'left', scopedSlots: { customRender: 'address' } },
{ { title: '{{ i18n "pages.inbounds.traffic" }}', align: 'left', width: 190, scopedSlots: { customRender: 'traffic' } },
title: '{{ i18n "pages.xray.outbound.tag"}}', { title: '{{ i18n "pages.xray.outbound.testResult" }}', align: 'left', width: 130, scopedSlots: { customRender: 'testResult' } },
dataIndex: 'tag', { title: '{{ i18n "pages.xray.outbound.test" }}', align: 'center', width: 70, scopedSlots: { customRender: 'test' } },
align: 'center',
width: 50
},
{
title: '{{ i18n "protocol"}}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'protocol'
}
},
{
title: '{{ i18n "pages.xray.outbound.address"}}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'address'
}
},
{
title: '{{ i18n "pages.inbounds.traffic" }}',
align: 'center',
width: 50,
scopedSlots: {
customRender: 'traffic'
}
},
{
title: '{{ i18n "pages.xray.outbound.testResult" }}',
align: 'center',
width: 120,
scopedSlots: {
customRender: 'testResult'
}
},
{
title: '{{ i18n "pages.xray.outbound.test" }}',
align: 'center',
width: 60,
scopedSlots: {
customRender: 'test'
}
},
]; ];
const reverseColumns = [{ const reverseColumns = [{
@@ -923,13 +757,64 @@
} }
return true; return true;
}, },
findOutboundTraffic(o) { // outboundTrafficFor returns {up, down} for an outbound by tag,
for (const otraffic of this.outboundsTraffic) { // defaulting to zeros when no traffic row has been reported yet.
if (otraffic.tag == o.tag) { // Templates use the up/down accessors below — keeping the lookup in
return `${SizeFormatter.sizeFormat(otraffic.up)} / ${SizeFormatter.sizeFormat(otraffic.down)}` // one place avoids drift if the data shape changes.
} outboundTrafficFor(o) {
} const t = this.outboundsTraffic.find(t => t.tag == o.tag);
return `${SizeFormatter.sizeFormat(0)} / ${SizeFormatter.sizeFormat(0)}` return { up: t ? t.up : 0, down: t ? t.down : 0 };
},
findOutboundUp(o) { return this.outboundTrafficFor(o).up; },
findOutboundDown(o) { return this.outboundTrafficFor(o).down; },
// One tone per category instead of per-value. Adding a new protocol or
// transport inherits the category colour — no styling work required.
// Hierarchy: emerald (protocol — primary identity, matches brand) →
// slate (network — transport is plumbing, sits back) → violet (security —
// accent, only rendered for tls/reality so a stand-out hue is earned).
outboundProtocolTone() { return 'tone-emerald'; },
outboundNetworkTone() { return 'tone-slate'; },
outboundSecurityTone() { return 'tone-violet'; },
// Whether the security label is one we render as a pill in the table.
isOutboundSecurityVisible(security) {
return security === 'tls' || security === 'reality';
},
// Null-safe accessor for the address list — collapses null/undefined
// returns from findOutboundAddress() into an empty array so the template
// can rely on .length and v-for without extra guards.
outboundAddresses(o) {
return this.findOutboundAddress(o) || [];
},
// Test-state accessors — sparse arrays + per-row state make raw checks
// verbose; these helpers keep the template readable and consistent.
isOutboundTesting(index) {
const s = this.outboundTestStates[index];
return !!(s && s.testing);
},
outboundTestResult(index) {
const s = this.outboundTestStates[index];
return s ? s.result : null;
},
isOutboundUntestable(outbound) {
return outbound.protocol === 'blackhole' || outbound.tag === 'blocked';
},
// csv splits a comma-separated rule field into trimmed non-empty values.
// Routing rule data uses CSV strings for multi-value criteria (e.g.
// sourceIP "1.2.3.0/24,4.5.6.0/24"); the modern table renders each
// criterion as a single summary pill, so values are normally re-joined
// via joinCsv() but this helper is kept for callers that need an array.
csv(value) {
if (!value) return [];
return String(value)
.split(',')
.map(v => v.trim())
.filter(v => v.length > 0);
},
// joinCsv normalises a CSV-style rule field into a single comma-space
// separated string suitable for tooltips. Returns '' for empty inputs
// so v-if guards can short-circuit on the raw rule field.
joinCsv(value) {
return this.csv(value).join(', ');
}, },
findOutboundAddress(o) { findOutboundAddress(o) {
serverObj = null; serverObj = null;
@@ -2136,4 +2021,421 @@
}, },
}); });
</script> </script>
<style>
/* ───────── Modern outbounds table ─────────
Visual goals:
• flat surface, no inner cell borders, only subtle row dividers
• rounded pill badges for protocol / tag / addresses
• dual-arrow traffic widget that aligns across rows
• consistent hover/loading/result states
Scoped under .xray-page .outbounds-modern so it doesn't bleed into other tables. */
.xray-page .outbounds-modern { width: 100%; }
.xray-page .outbounds-toolbar-right { text-align: right; }
/* Table chrome */
.xray-page .outbounds-table .ant-table {
background: transparent;
border-radius: 14px;
overflow: hidden;
}
.xray-page .outbounds-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 14px 18px;
}
.light .xray-page .outbounds-table .ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.55);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .outbounds-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px 18px;
transition: background-color 0.15s ease;
vertical-align: middle;
}
/* Force every cell to honour its column width — long content (especially
long tags) must clip via cell-level ellipsis instead of pushing the row
taller. */
.xray-page .outbounds-table .ant-table-tbody > tr > td,
.xray-page .outbounds-table .ant-table-thead > tr > th {
overflow: hidden;
}
.light .xray-page .outbounds-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.xray-page .outbounds-table .ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.xray-page .outbounds-table .ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.035) !important;
}
.light .xray-page .outbounds-table .ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.025) !important;
}
/* Index + actions column */
.xray-page .outbound-action-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .outbound-index {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: end;
}
.light .xray-page .outbound-index { color: rgba(0, 0, 0, 0.7); }
.xray-page .outbound-action-btn {
border: none;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
transition: background 0.15s ease;
}
.xray-page .outbound-action-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.light .xray-page .outbound-action-btn {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.75);
}
.light .xray-page .outbound-action-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
/* Identity cell — tag on top, protocol/network/security pills underneath.
Combining the two columns lets the table fit common viewports without
a horizontal scrollbar. */
.xray-page .outbound-identity-cell {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
/* Tag — inherits the table's font for visual parity, single line with
ellipsis on overflow. A long tag (e.g. "vless_jphttp-ksjpnggl") would
otherwise wrap and inflate the row's height; the inline tooltip surfaces
the full value on hover. */
.xray-page .outbound-tag {
font-size: 13px;
color: rgba(255, 255, 255, 0.92);
font-weight: 500;
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.light .xray-page .outbound-tag { color: rgba(0, 0, 0, 0.85); }
/* Address pills (monospace, monoline) */
.xray-page .outbound-address-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.xray-page .outbound-address-pill {
font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
font-size: 12px;
padding: 3px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.045);
color: rgba(255, 255, 255, 0.78);
line-height: 1.5;
border: 1px solid rgba(255, 255, 255, 0.06);
display: inline-block;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.light .xray-page .outbound-address-pill {
background: rgba(0, 0, 0, 0.035);
color: rgba(0, 0, 0, 0.78);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .outbound-address-empty {
color: rgba(255, 255, 255, 0.3);
font-style: italic;
}
/* Protocol/network/tls pills — shared "outbound-pill" with tonal modifiers.
The pill row stays on a single line; if the column is somehow too narrow
for all pills it overflows out of view (rare — column width is sized to
fit the worst case) but never pushes the row taller. */
.xray-page .outbound-protocol-cell {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
overflow: hidden;
}
.xray-page .outbound-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 9px;
border-radius: 11px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.01em;
border: 1px solid transparent;
white-space: nowrap;
flex: 0 0 auto;
}
/* Outbound pill tones: emerald = protocol, slate = network, violet = security.
tone-emerald and tone-violet are also consumed by routing.html for the
outboundTag / balancerTag pills. */
.xray-page .outbound-pill.tone-emerald { background: rgba(0, 191, 165, 0.14); color: #4dd4be; border-color: rgba(0, 191, 165, 0.28); }
.xray-page .outbound-pill.tone-slate { background: rgba(160, 174, 192, 0.14); color: #b8c2d0; border-color: rgba(160, 174, 192, 0.26); }
.xray-page .outbound-pill.tone-violet { background: rgba(155, 89, 219, 0.16); color: #b489e8; border-color: rgba(155, 89, 219, 0.32); }
/* Traffic — dual arrow widget, fixed columns so all rows align */
.xray-page .outbound-traffic-cell {
display: inline-grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 10px;
padding: 5px 12px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.04);
font-variant-numeric: tabular-nums;
font-size: 13px;
min-width: 0;
}
.light .xray-page .outbound-traffic-cell {
background: rgba(0, 0, 0, 0.035);
}
.xray-page .outbound-traffic-up,
.xray-page .outbound-traffic-down {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.xray-page .outbound-traffic-up { justify-content: flex-end; color: #4dd4be; }
.xray-page .outbound-traffic-down { justify-content: flex-start; color: #82a7ee; }
.xray-page .outbound-traffic-up .anticon,
.xray-page .outbound-traffic-down .anticon { font-size: 11px; }
.xray-page .outbound-traffic-sep {
width: 1px;
height: 14px;
background: rgba(255, 255, 255, 0.12);
border-radius: 1px;
}
.light .xray-page .outbound-traffic-sep { background: rgba(0, 0, 0, 0.12); }
/* Test result pills */
.xray-page .outbound-result-cell { display: inline-flex; }
.xray-page .outbound-result-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 100px;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
white-space: nowrap;
border: 1px solid transparent;
}
.xray-page .outbound-result-pill .anticon { font-size: 12px; }
.xray-page .outbound-result-ok {
background: rgba(0, 191, 165, 0.14);
color: #4dd4be;
border-color: rgba(0, 191, 165, 0.28);
}
.xray-page .outbound-result-fail {
background: rgba(255, 77, 79, 0.14);
color: #ff7a7c;
border-color: rgba(255, 77, 79, 0.32);
}
.xray-page .outbound-result-status { opacity: 0.75; }
.xray-page .outbound-result-loading,
.xray-page .outbound-result-idle {
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
}
.light .xray-page .outbound-result-loading,
.light .xray-page .outbound-result-idle { color: rgba(0, 0, 0, 0.4); }
/* Test button — sleek circular with subtle glow */
.xray-page .outbound-test-btn {
box-shadow: 0 2px 8px rgba(0, 191, 165, 0.18);
transition: transform 0.12s ease, box-shadow 0.18s ease;
}
.xray-page .outbound-test-btn:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(0, 191, 165, 0.32);
}
.xray-page .outbound-test-btn[disabled] {
box-shadow: none;
opacity: 0.45;
}
/* ───────── Modern routing-rules table ─────────
Reuses the .outbound-pill tonal primitive (identical visual) so the
routing tab feels like the same panel as outbounds. Each cell groups
a routing criterion (Source / Network / Destination / Inbound) and
shows its values as labelled pills. */
.xray-page .routing-modern { width: 100%; }
.xray-page .routing-table .ant-table {
background: transparent;
border-radius: 14px;
overflow: hidden;
}
.xray-page .routing-table .ant-table-thead > tr > th {
background: rgba(255, 255, 255, 0.025);
color: rgba(255, 255, 255, 0.55);
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 14px 18px;
}
.light .xray-page .routing-table .ant-table-thead > tr > th {
background: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.55);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.xray-page .routing-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px 18px;
transition: background-color 0.15s ease;
vertical-align: top;
}
.light .xray-page .routing-table .ant-table-tbody > tr > td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.xray-page .routing-table .ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.xray-page .routing-table .ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.035) !important;
}
.light .xray-page .routing-table .ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.025) !important;
}
/* Sort handle / # / actions */
.xray-page .routing-action-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .routing-index {
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: end;
}
.light .xray-page .routing-index { color: rgba(0, 0, 0, 0.7); }
.xray-page .routing-action-btn {
border: none;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.75);
transition: background 0.15s ease;
}
.xray-page .routing-action-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.light .xray-page .routing-action-btn {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.75);
}
.light .xray-page .routing-action-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
/* Plain-text criterion rows — replaces pill primitives in condition
columns. Each criterion is a row of "label value (+N)" with form-label
styling on the label. No bg, no border, no color tones — keeps cells
light and lets the column header carry the type semantic. The cell's
visual weight is now proportional only to the data length, not to
decoration. The single colored pill in Outbound/Balancer remains as
the row's focal point. */
.xray-page .criterion-flow {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.xray-page .criterion-row {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
font-size: 13px;
line-height: 1.5;
}
.xray-page .criterion-label {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.42);
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.light .xray-page .criterion-label { color: rgba(0, 0, 0, 0.45); }
.xray-page .criterion-value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.85);
}
.light .xray-page .criterion-value { color: rgba(0, 0, 0, 0.85); }
.xray-page .criterion-more {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.42);
font-weight: 500;
}
.light .xray-page .criterion-more { color: rgba(0, 0, 0, 0.45); }
.xray-page .routing-criteria-empty {
color: rgba(255, 255, 255, 0.3);
font-style: italic;
}
.light .xray-page .routing-criteria-empty { color: rgba(0, 0, 0, 0.3); }
/* Target cell (outbound / balancer) — vertically stacked rows of icon + pill */
.xray-page .routing-target-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.xray-page .routing-target-row {
display: inline-flex;
align-items: center;
gap: 8px;
}
.xray-page .routing-target-icon {
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.light .xray-page .routing-target-icon { color: rgba(0, 0, 0, 0.45); }
</style>
{{ template "page/body_end" .}} {{ template "page/body_end" .}}

View File

@@ -24,7 +24,9 @@ func NewXrayTrafficJob() *XrayTrafficJob {
return new(XrayTrafficJob) return new(XrayTrafficJob)
} }
// Run collects traffic statistics from Xray and updates the database, triggering restart if needed. // Run collects traffic statistics from Xray, updates the database, and pushes
// real-time updates over WebSocket using compact delta payloads — no REST
// fallback, scales to 10k20k+ clients per inbound.
func (j *XrayTrafficJob) Run() { func (j *XrayTrafficJob) Run() {
if !j.xrayService.IsXrayRunning() { if !j.xrayService.IsXrayRunning() {
return return
@@ -33,7 +35,7 @@ func (j *XrayTrafficJob) Run() {
if err != nil { if err != nil {
return return
} }
err, needRestart0, clientsDisabled := j.inboundService.AddTraffic(traffics, clientTraffics) needRestart0, clientsDisabled, err := j.inboundService.AddTraffic(traffics, clientTraffics)
if err != nil { if err != nil {
logger.Warning("add inbound traffic failed:", err) logger.Warning("add inbound traffic failed:", err)
} }
@@ -62,50 +64,85 @@ func (j *XrayTrafficJob) Run() {
j.xrayService.SetToNeedRestart() j.xrayService.SetToNeedRestart()
} }
// If no frontend client is connected, skip all WebSocket broadcasting routines, // If no frontend client is connected, skip all WebSocket broadcasting
// including expensive DB queries for online clients and JSON marshaling. // routines — including the active-client DB query and JSON marshaling.
if !websocket.HasClients() { if !websocket.HasClients() {
return return
} }
// Update online clients list and map // Online presence + traffic deltas — small payload, always fits in WS.
// Force non-nil slice/map so JSON marshalling produces [] / {} instead of
// `null` when everyone is offline. The frontend's traffic handler treats
// a missing/null onlineClients field as "no update", so without this the
// "everyone went offline" transition was silently dropped — stale online
// users lingered in the list and the online filter kept showing them.
onlineClients := j.inboundService.GetOnlineClients() onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
lastOnlineMap, err := j.inboundService.GetClientsLastOnline() lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil { if err != nil {
logger.Warning("get clients last online failed:", err) logger.Warning("get clients last online failed:", err)
}
if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64) lastOnlineMap = make(map[string]int64)
} }
websocket.BroadcastTraffic(map[string]any{
// Broadcast traffic update (deltas and online stats) via WebSocket
trafficUpdate := map[string]any{
"traffics": traffics, "traffics": traffics,
"clientTraffics": clientTraffics, "clientTraffics": clientTraffics,
"onlineClients": onlineClients, "onlineClients": onlineClients,
"lastOnlineMap": lastOnlineMap, "lastOnlineMap": lastOnlineMap,
} })
websocket.BroadcastTraffic(trafficUpdate)
// Fetch updated inbounds from database with accumulated traffic values // Compact delta payload: per-client absolute counters for clients active
// This ensures frontend receives the actual total traffic for real-time UI refresh. // this cycle, plus inbound-level absolute totals. Frontend applies both
updatedInbounds, err := j.inboundService.GetAllInbounds() // in-place — typical payload ~1050KB even for 10k+ client deployments.
if err != nil { // Replaces the old full-inbound-list broadcast that hit WS size limits
logger.Warning("get all inbounds for websocket failed:", err) // (510MB) and forced the frontend into a REST refetch.
clientStatsPayload := map[string]any{}
if activeEmails := activeEmails(clientTraffics); len(activeEmails) > 0 {
if stats, err := j.inboundService.GetActiveClientTraffics(activeEmails); err != nil {
logger.Warning("get active client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStatsPayload["clients"] = stats
}
}
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
logger.Warning("get inbounds traffic summary for websocket failed:", err)
} else if len(inboundSummary) > 0 {
clientStatsPayload["inbounds"] = inboundSummary
}
if len(clientStatsPayload) > 0 {
websocket.BroadcastClientStats(clientStatsPayload)
} }
updatedOutbounds, err := j.outboundService.GetOutboundsTraffic() // Outbounds list is small (one row per outbound, no per-client expansion)
if err != nil { // so the full snapshot still fits comfortably in WS.
if updatedOutbounds, err := j.outboundService.GetOutboundsTraffic(); err == nil && updatedOutbounds != nil {
websocket.BroadcastOutbounds(updatedOutbounds)
} else if err != nil {
logger.Warning("get all outbounds for websocket failed:", err) logger.Warning("get all outbounds for websocket failed:", err)
} }
// The WebSocket hub will automatically check the payload size.
// If it exceeds 100MB, it sends a lightweight 'invalidate' signal instead.
if updatedInbounds != nil {
websocket.BroadcastInbounds(updatedInbounds)
} }
if updatedOutbounds != nil { // activeEmails returns the set of client emails that had non-zero traffic in
websocket.BroadcastOutbounds(updatedOutbounds) // the current collection window. Idle clients are skipped — no need to push
// their (unchanged) counters to the frontend.
func activeEmails(clientTraffics []*xray.ClientTraffic) []string {
if len(clientTraffics) == 0 {
return nil
} }
emails := make([]string, 0, len(clientTraffics))
for _, ct := range clientTraffics {
if ct == nil || ct.Email == "" {
continue
}
if ct.Up == 0 && ct.Down == 0 {
continue
}
emails = append(emails, ct.Email)
}
return emails
} }
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) { func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {

View File

@@ -366,12 +366,24 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
for _, client := range clients { // Bulk-delete client IPs for every email in this inbound. The previous
err := s.DelClientIPs(db, client.Email) // per-client loop fired one DELETE per row — at 7k+ clients that meant
if err != nil { // thousands of synchronous SQL roundtrips and a multi-second freeze.
// Chunked to stay under SQLite's bind-variable limit on huge inbounds.
if len(clients) > 0 {
emails := make([]string, 0, len(clients))
for i := range clients {
if clients[i].Email != "" {
emails = append(emails, clients[i].Email)
}
}
for _, batch := range chunkStrings(uniqueNonEmptyStrings(emails), sqliteMaxVars) {
if err := db.Where("client_email IN ?", batch).
Delete(model.InboundClientIps{}).Error; err != nil {
return false, err return false, err
} }
} }
}
return needRestart, db.Delete(model.Inbound{}, id).Error return needRestart, db.Delete(model.Inbound{}, id).Error
} }
@@ -386,6 +398,66 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
return inbound, nil return inbound, nil
} }
// SetInboundEnable toggles only the enable flag of an inbound, without
// rewriting the (potentially multi-MB) settings JSON. Used by the UI's
// per-row enable switch — for inbounds with thousands of clients the full
// UpdateInbound path is an order of magnitude too slow for an interactive
// toggle (parses + reserialises every client, runs O(N) traffic diff).
//
// Returns (needRestart, error). needRestart is true when the xray runtime
// could not be re-synced from the cached config and a full restart is
// required to pick up the change.
func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
inbound, err := s.GetInbound(id)
if err != nil {
return false, err
}
if inbound.Enable == enable {
return false, nil
}
db := database.GetDB()
if err := db.Model(model.Inbound{}).Where("id = ?", id).
Update("enable", enable).Error; err != nil {
return false, err
}
inbound.Enable = enable
// Sync xray runtime: drop the live inbound, add it back if we're enabling.
// "User not found"-style errors from DelInbound mean the inbound was
// already absent from the live config — that's fine. Any other error
// means the live config and DB diverged, so we ask the caller to
// schedule a restart.
needRestart := false
s.xrayApi.Init(p.GetAPIPort())
defer s.xrayApi.Close()
if err := s.xrayApi.DelInbound(inbound.Tag); err != nil &&
!strings.Contains(err.Error(), "not found") {
logger.Debug("SetInboundEnable: DelInbound via api failed:", err)
needRestart = true
}
if !enable {
return needRestart, nil
}
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
}
inboundJson, err := json.MarshalIndent(runtimeInbound.GenXrayInboundConfig(), "", " ")
if err != nil {
logger.Debug("SetInboundEnable: marshal runtime config failed:", err)
return true, nil
}
if err := s.xrayApi.AddInbound(inboundJson); err != nil {
logger.Debug("SetInboundEnable: AddInbound via api failed:", err)
needRestart = true
}
return needRestart, nil
}
// UpdateInbound modifies an existing inbound configuration. // UpdateInbound modifies an existing inbound configuration.
// It validates changes, updates the database, and syncs with the running Xray instance. // It validates changes, updates the database, and syncs with the running Xray instance.
// Returns the updated inbound, whether Xray needs restart, and any error. // Returns the updated inbound, whether Xray needs restart, and any error.
@@ -589,6 +661,11 @@ func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.I
return &runtimeInbound, nil return &runtimeInbound, nil
} }
// updateClientTraffics syncs the ClientTraffic rows with the inbound's clients
// list: removes rows for emails that disappeared, inserts rows for newly-added
// emails. Uses sets for O(N) lookup — the previous nested-loop implementation
// was O(N²) and degraded into multi-second pauses on inbounds with thousands
// of clients (toggling, saving, or deleting any such inbound felt frozen).
func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inbound, newInbound *model.Inbound) error { func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inbound, newInbound *model.Inbound) error {
oldClients, err := s.GetClients(oldInbound) oldClients, err := s.GetClients(oldInbound)
if err != nil { if err != nil {
@@ -599,38 +676,50 @@ func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inb
return err return err
} }
var emailExists bool // Email is the unique key for ClientTraffic rows. Clients without an
// email have no stats row to sync — skip them on both sides instead of
// risking a unique-constraint hit or accidental delete of an unrelated row.
oldEmails := make(map[string]struct{}, len(oldClients))
for i := range oldClients {
if oldClients[i].Email == "" {
continue
}
oldEmails[oldClients[i].Email] = struct{}{}
}
newEmails := make(map[string]struct{}, len(newClients))
for i := range newClients {
if newClients[i].Email == "" {
continue
}
newEmails[newClients[i].Email] = struct{}{}
}
for _, oldClient := range oldClients { // Removed clients — drop their stats rows.
emailExists = false for i := range oldClients {
for _, newClient := range newClients { email := oldClients[i].Email
if oldClient.Email == newClient.Email { if email == "" {
emailExists = true continue
break
} }
if _, kept := newEmails[email]; kept {
continue
} }
if !emailExists { if err := s.DelClientStat(tx, email); err != nil {
err = s.DelClientStat(tx, oldClient.Email)
if err != nil {
return err return err
} }
} }
// Added clients — create their stats rows.
for i := range newClients {
email := newClients[i].Email
if email == "" {
continue
} }
for _, newClient := range newClients { if _, existed := oldEmails[email]; existed {
emailExists = false continue
for _, oldClient := range oldClients {
if newClient.Email == oldClient.Email {
emailExists = true
break
} }
} if err := s.AddClientStat(tx, oldInbound.Id, &newClients[i]); err != nil {
if !emailExists {
err = s.AddClientStat(tx, oldInbound.Id, &newClient)
if err != nil {
return err return err
} }
} }
}
return nil return nil
} }
@@ -1228,7 +1317,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
return needRestart, tx.Save(oldInbound).Error return needRestart, tx.Save(oldInbound).Error
} }
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool, bool) { func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
var err error var err error
db := database.GetDB() db := database.GetDB()
tx := db.Begin() tx := db.Begin()
@@ -1242,11 +1331,11 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
}() }()
err = s.addInboundTraffic(tx, inboundTraffics) err = s.addInboundTraffic(tx, inboundTraffics)
if err != nil { if err != nil {
return err, false, false return false, false, err
} }
err = s.addClientTraffic(tx, clientTraffics) err = s.addClientTraffic(tx, clientTraffics)
if err != nil { if err != nil {
return err, false, false return false, false, err
} }
needRestart0, count, err := s.autoRenewClients(tx) needRestart0, count, err := s.autoRenewClients(tx)
@@ -1271,7 +1360,7 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
} else if count > 0 { } else if count > 0 {
logger.Debugf("%v inbounds disabled", count) logger.Debugf("%v inbounds disabled", count)
} }
return nil, (needRestart0 || needRestart1 || needRestart2), disabledClientsCount > 0 return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
} }
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error { func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
@@ -1328,20 +1417,27 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
return err return err
} }
// Index by email for O(N) merge — the previous nested loop was O(N²)
// and dominated each cron tick on inbounds with thousands of active
// clients (7500 × 7500 = 56M string comparisons every 10 seconds).
trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
for i := range traffics {
if traffics[i] != nil {
trafficByEmail[traffics[i].Email] = traffics[i]
}
}
now := time.Now().UnixMilli()
for dbTraffic_index := range dbClientTraffics { for dbTraffic_index := range dbClientTraffics {
for traffic_index := range traffics { t, ok := trafficByEmail[dbClientTraffics[dbTraffic_index].Email]
if dbClientTraffics[dbTraffic_index].Email == traffics[traffic_index].Email { if !ok {
dbClientTraffics[dbTraffic_index].Up += traffics[traffic_index].Up continue
dbClientTraffics[dbTraffic_index].Down += traffics[traffic_index].Down
dbClientTraffics[dbTraffic_index].AllTime += (traffics[traffic_index].Up + traffics[traffic_index].Down)
// Add user in onlineUsers array on traffic
if traffics[traffic_index].Up+traffics[traffic_index].Down > 0 {
onlineClients = append(onlineClients, traffics[traffic_index].Email)
dbClientTraffics[dbTraffic_index].LastOnline = time.Now().UnixMilli()
}
break
} }
dbClientTraffics[dbTraffic_index].Up += t.Up
dbClientTraffics[dbTraffic_index].Down += t.Down
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
if t.Up+t.Down > 0 {
onlineClients = append(onlineClients, t.Email)
dbClientTraffics[dbTraffic_index].LastOnline = now
} }
} }
@@ -1441,10 +1537,18 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
for _, traffic := range traffics { for _, traffic := range traffics {
inbound_ids = append(inbound_ids, traffic.InboundId) inbound_ids = append(inbound_ids, traffic.InboundId)
} }
err = tx.Model(model.Inbound{}).Where("id IN ?", inbound_ids).Find(&inbounds).Error // Dedupe so an inbound hosting N expired clients is fetched and saved once
if err != nil { // per tick instead of N times across chunk boundaries.
inbound_ids = uniqueInts(inbound_ids)
// Chunked to stay under SQLite's bind-variable limit when many inbounds
// are touched in a single tick.
for _, batch := range chunkInts(inbound_ids, sqliteMaxVars) {
var page []*model.Inbound
if err = tx.Model(model.Inbound{}).Where("id IN ?", batch).Find(&page).Error; err != nil {
return false, 0, err return false, 0, err
} }
inbounds = append(inbounds, page...)
}
for inbound_index := range inbounds { for inbound_index := range inbounds {
settings := map[string]any{} settings := map[string]any{}
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings) json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
@@ -2362,16 +2466,25 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
} }
} }
var traffics []*xray.ClientTraffic // Chunked to stay under SQLite's bind-variable limit when a single Telegram
err = db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&traffics).Error // account owns thousands of clients across inbounds.
if err != nil { uniqEmails := uniqueNonEmptyStrings(emails)
traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
var page []*xray.ClientTraffic
if err = db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
continue
}
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
return nil, err
}
traffics = append(traffics, page...)
}
if len(traffics) == 0 {
logger.Warning("No ClientTraffic records found for emails:", emails) logger.Warning("No ClientTraffic records found for emails:", emails)
return nil, nil return nil, nil
} }
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", emails, err)
return nil, err
}
// Populate UUID and other client data for each traffic record // Populate UUID and other client data for each traffic record
for i := range traffics { for i := range traffics {
@@ -2385,6 +2498,133 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
return traffics, nil return traffics, nil
} }
// sqliteMaxVars is a safe ceiling for the number of bind parameters in a
// single SQL statement. SQLite's SQLITE_MAX_VARIABLE_NUMBER is 999 on builds
// before 3.32 and 32766 after; staying under 999 keeps queries portable
// across forks/old binaries and also bounds per-query memory on truly large
// installs (>32k clients) where even modern SQLite would refuse a single IN.
const sqliteMaxVars = 900
// uniqueNonEmptyStrings returns a deduplicated copy of in with empty strings
// removed, preserving the order of first occurrence.
func uniqueNonEmptyStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, v := range in {
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
// uniqueInts returns a deduplicated copy of in, preserving order of first occurrence.
func uniqueInts(in []int) []int {
if len(in) == 0 {
return nil
}
seen := make(map[int]struct{}, len(in))
out := make([]int, 0, len(in))
for _, v := range in {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
// chunkStrings splits s into consecutive sub-slices of at most size elements.
// Returns nil for an empty input or non-positive size.
func chunkStrings(s []string, size int) [][]string {
if size <= 0 || len(s) == 0 {
return nil
}
out := make([][]string, 0, (len(s)+size-1)/size)
for i := 0; i < len(s); i += size {
end := i + size
if end > len(s) {
end = len(s)
}
out = append(out, s[i:end])
}
return out
}
// chunkInts splits s into consecutive sub-slices of at most size elements.
// Returns nil for an empty input or non-positive size.
func chunkInts(s []int, size int) [][]int {
if size <= 0 || len(s) == 0 {
return nil
}
out := make([][]int, 0, (len(s)+size-1)/size)
for i := 0; i < len(s); i += size {
end := i + size
if end > len(s) {
end = len(s)
}
out = append(out, s[i:end])
}
return out
}
// GetActiveClientTraffics returns the absolute ClientTraffic rows for the given
// emails. Used by the WebSocket delta path to push per-client absolute
// counters without re-serializing the full inbound list. The query is chunked
// to stay under SQLite's bind-variable limit on very large active sets.
// Empty input returns (nil, nil).
func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
uniq := uniqueNonEmptyStrings(emails)
if len(uniq) == 0 {
return nil, nil
}
db := database.GetDB()
traffics := make([]*xray.ClientTraffic, 0, len(uniq))
for _, batch := range chunkStrings(uniq, sqliteMaxVars) {
var page []*xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
return nil, err
}
traffics = append(traffics, page...)
}
return traffics, nil
}
// InboundTrafficSummary is the minimal projection of an inbound's traffic
// counters used by the WebSocket delta path. Excludes Settings/StreamSettings
// blobs so the broadcast stays compact even with many inbounds.
type InboundTrafficSummary struct {
Id int `json:"id"`
Up int64 `json:"up"`
Down int64 `json:"down"`
Total int64 `json:"total"`
AllTime int64 `json:"allTime"`
Enable bool `json:"enable"`
}
// GetInboundsTrafficSummary returns inbound-level absolute traffic counters
// (no per-client expansion). Companion to GetActiveClientTraffics — together
// they replace the heavy "full inbound list" broadcast on each cron tick.
func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, error) {
db := database.GetDB()
var summaries []InboundTrafficSummary
if err := db.Model(&model.Inbound{}).
Select("id, up, down, total, all_time, enable").
Find(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) { func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
// Prefer retrieving along with client to reflect actual enabled state from inbound settings // Prefer retrieving along with client to reflect actual enabled state from inbound settings
t, client, err := s.GetClientByEmail(email) t, client, err := s.GetClientByEmail(email)
@@ -2403,9 +2643,17 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64, download int64) error { func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64, download int64) error {
db := database.GetDB() db := database.GetDB()
// Keep all_time monotonic: it represents historical cumulative usage and
// must never be less than the currently-tracked up+down. Without this,
// the UI showed "Общий трафик" (allTime) below the live consumed value
// after admins manually edited a client's counters.
result := db.Model(xray.ClientTraffic{}). result := db.Model(xray.ClientTraffic{}).
Where("email = ?", email). Where("email = ?", email).
Updates(map[string]any{"up": upload, "down": download}) Updates(map[string]any{
"up": upload,
"down": download,
"all_time": gorm.Expr("CASE WHEN COALESCE(all_time, 0) < ? THEN ? ELSE all_time END", upload+download, upload+download),
})
err := result.Error err := result.Error
if err != nil { if err != nil {
@@ -2746,12 +2994,17 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) { func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB() db := database.GetDB()
// Step 1: Get ClientTraffic records for emails in the input list // Step 1: Get ClientTraffic records for emails in the input list.
var clients []xray.ClientTraffic // Chunked to stay under SQLite's bind-variable limit on huge inputs.
err := db.Where("email IN ?", emails).Find(&clients).Error uniqEmails := uniqueNonEmptyStrings(emails)
if err != nil && err != gorm.ErrRecordNotFound { clients := make([]xray.ClientTraffic, 0, len(uniqEmails))
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
var page []xray.ClientTraffic
if err := db.Where("email IN ?", batch).Find(&page).Error; err != nil && err != gorm.ErrRecordNotFound {
return nil, nil, err return nil, nil, err
} }
clients = append(clients, page...)
}
// Step 2: Sort clients by (Up + Down) descending // Step 2: Sort clients by (Up + Down) descending
sort.Slice(clients, func(i, j int) bool { sort.Slice(clients, func(i, j int) bool {

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -20,14 +21,16 @@ func init() {
gob.Register(model.User{}) gob.Register(model.User{})
} }
// SetLoginUser stores the authenticated user in the session. // SetLoginUser stores the authenticated user in the session and persists it.
// The user object is serialized and stored for subsequent requests. // gin-contrib/sessions does not auto-save; callers that forget Save() leave
func SetLoginUser(c *gin.Context, user *model.User) { // the cookie out of sync with server state — this helper avoids that pitfall.
func SetLoginUser(c *gin.Context, user *model.User) error {
if user == nil { if user == nil {
return return nil
} }
s := sessions.Default(c) s := sessions.Default(c)
s.Set(loginUserKey, *user) s.Set(loginUserKey, *user)
return s.Save()
} }
// GetLoginUser retrieves the authenticated user from the session. // GetLoginUser retrieves the authenticated user from the session.
@@ -40,22 +43,26 @@ func GetLoginUser(c *gin.Context) *model.User {
} }
user, ok := obj.(model.User) user, ok := obj.(model.User)
if !ok { if !ok {
// Stale or incompatible session payload — wipe and persist immediately
// so subsequent requests don't keep hitting the same broken cookie.
s.Delete(loginUserKey) s.Delete(loginUserKey)
if err := s.Save(); err != nil {
logger.Warning("session: failed to drop stale user payload:", err)
}
return nil return nil
} }
return &user return &user
} }
// IsLogin checks if a user is currently authenticated in the session. // IsLogin checks if a user is currently authenticated in the session.
// Returns true if a valid user session exists, false otherwise.
func IsLogin(c *gin.Context) bool { func IsLogin(c *gin.Context) bool {
return GetLoginUser(c) != nil return GetLoginUser(c) != nil
} }
// ClearSession removes all session data and invalidates the session. // ClearSession invalidates the session and tells the browser to drop the cookie.
// This effectively logs out the user and clears any stored session information. // The cookie attributes (Path/HttpOnly/SameSite) must mirror those used when
func ClearSession(c *gin.Context) { // the cookie was created or browsers will keep it.
func ClearSession(c *gin.Context) error {
s := sessions.Default(c) s := sessions.Default(c)
s.Clear() s.Clear()
cookiePath := c.GetString("base_path") cookiePath := c.GetString("base_path")
@@ -68,4 +75,5 @@ func ClearSession(c *gin.Context) {
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
return s.Save()
} }

View File

@@ -1,402 +1,379 @@
// Package websocket provides WebSocket hub for real-time updates and notifications. // Package websocket provides a WebSocket hub for real-time updates and notifications.
package websocket package websocket
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"runtime"
"sync" "sync"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/logger"
) )
// MessageType represents the type of WebSocket message // MessageType identifies the kind of WebSocket message.
type MessageType string type MessageType string
const ( const (
MessageTypeStatus MessageType = "status" // Server status update MessageTypeStatus MessageType = "status"
MessageTypeTraffic MessageType = "traffic" // Traffic statistics update MessageTypeTraffic MessageType = "traffic"
MessageTypeInbounds MessageType = "inbounds" // Inbounds list update MessageTypeInbounds MessageType = "inbounds"
MessageTypeNotification MessageType = "notification" // System notification MessageTypeOutbounds MessageType = "outbounds"
MessageTypeXrayState MessageType = "xray_state" // Xray state change MessageTypeNotification MessageType = "notification"
MessageTypeOutbounds MessageType = "outbounds" // Outbounds list update MessageTypeXrayState MessageType = "xray_state"
MessageTypeInvalidate MessageType = "invalidate" // Lightweight signal telling frontend to re-fetch data via REST // MessageTypeClientStats carries absolute traffic counters for the clients
// that had activity in the latest collection window. Frontend applies these
// in-place — far smaller than re-broadcasting the full inbound list and
// scales to 10k+ clients without falling back to REST.
MessageTypeClientStats MessageType = "client_stats"
MessageTypeInvalidate MessageType = "invalidate" // Tells frontend to re-fetch via REST (last-resort).
// maxMessageSize caps the WebSocket payload. Beyond this the hub sends a
// lightweight invalidate signal and the frontend re-fetches via REST.
// 10MB lets typical 2k8k-client deployments push directly via WS (low
// latency); larger installs fall back to invalidate.
maxMessageSize = 10 * 1024 * 1024 // 10MB
enqueueTimeout = 100 * time.Millisecond
clientSendQueue = 512 // ~50s of buffering for a momentarily slow browser.
hubBroadcastQueue = 2048 // Headroom for cron-storm + admin-mutation bursts.
hubControlQueue = 64 // Backlog for register/unregister bursts (page reloads, disconnect storms).
// minBroadcastInterval throttles per-type broadcasts so cron storms or
// rapid mutations cannot drown the hub. Bursts within the interval are
// dropped (not coalesced); the next broadcast outside the window delivers
// the latest state. Only message types in throttledMessageTypes are gated —
// heartbeat and real-time signals (status, traffic, client_stats,
// notification, xray_state, invalidate) bypass this so they are never delayed.
minBroadcastInterval = 250 * time.Millisecond
// hubRestartAttempts caps panic-recovery restarts. After this many
// consecutive failures we stop trying and log; the panel keeps running
// (frontend falls back to REST polling) and the operator can investigate.
hubRestartAttempts = 3
) )
// Message represents a WebSocket message // NewClient builds a Client ready for hub registration.
func NewClient(id string) *Client {
return &Client{
ID: id,
Send: make(chan []byte, clientSendQueue),
}
}
// Message is the wire format sent to clients.
type Message struct { type Message struct {
Type MessageType `json:"type"` Type MessageType `json:"type"`
Payload any `json:"payload"` Payload any `json:"payload"`
Time int64 `json:"time"` Time int64 `json:"time"`
} }
// Client represents a WebSocket client connection // Client represents a single WebSocket connection.
type Client struct { type Client struct {
ID string ID string
Send chan []byte Send chan []byte
Hub *Hub closeOnce sync.Once
Topics map[MessageType]bool // Subscribed topics
closeOnce sync.Once // Ensures Send channel is closed exactly once
} }
// Hub maintains the set of active clients and broadcasts messages to them // Hub fan-outs messages to all connected clients.
type Hub struct { type Hub struct {
// Registered clients clients map[*Client]struct{}
clients map[*Client]bool
// Inbound messages from clients
broadcast chan []byte broadcast chan []byte
// Register requests from clients
register chan *Client register chan *Client
// Unregister requests from clients
unregister chan *Client unregister chan *Client
// Mutex for thread-safe operations
mu sync.RWMutex mu sync.RWMutex
// Context for graceful shutdown
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
// Worker pool for parallel broadcasting throttleMu sync.Mutex
workerPoolSize int lastBroadcast map[MessageType]time.Time
} }
// NewHub creates a new WebSocket hub // NewHub creates a hub. Call Run in a goroutine to start its event loop.
func NewHub() *Hub { func NewHub() *Hub {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
// Calculate optimal worker pool size (CPU cores * 2, but max 100)
workerPoolSize := runtime.NumCPU() * 2
if workerPoolSize > 100 {
workerPoolSize = 100
}
if workerPoolSize < 10 {
workerPoolSize = 10
}
return &Hub{ return &Hub{
clients: make(map[*Client]bool), clients: make(map[*Client]struct{}),
broadcast: make(chan []byte, 2048), // Increased from 256 to 2048 for high load broadcast: make(chan []byte, hubBroadcastQueue),
register: make(chan *Client, 100), // Buffered channel for fast registration register: make(chan *Client, hubControlQueue),
unregister: make(chan *Client, 100), // Buffered channel for fast unregistration unregister: make(chan *Client, hubControlQueue),
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
workerPoolSize: workerPoolSize, lastBroadcast: make(map[MessageType]time.Time),
} }
} }
// Run starts the hub's main loop // throttledMessageTypes is the explicit allow-list of message types subject to
// the per-type rate limit. Everything else (status, traffic, client_stats,
// notification, xray_state, invalidate) is heartbeat- or signal-class and must
// not be delayed. Keeping the set explicit (vs. an exclusion list) makes the
// intent obvious when new message types are added — by default they bypass.
var throttledMessageTypes = map[MessageType]struct{}{
MessageTypeInbounds: {},
MessageTypeOutbounds: {},
}
// shouldThrottle returns true if a broadcast of msgType is rate-limited and
// happened within minBroadcastInterval of the previous one. Only message types
// in throttledMessageTypes are gated.
func (h *Hub) shouldThrottle(msgType MessageType) bool {
if _, gated := throttledMessageTypes[msgType]; !gated {
return false
}
h.throttleMu.Lock()
defer h.throttleMu.Unlock()
now := time.Now()
if last, ok := h.lastBroadcast[msgType]; ok && now.Sub(last) < minBroadcastInterval {
return true
}
h.lastBroadcast[msgType] = now
return false
}
// Run drives the hub. The inner loop is wrapped in a panic-recovery harness
// that retries up to hubRestartAttempts times with backoff so a transient
// panic doesn't permanently kill real-time updates for commercial deployments.
// After the cap, the hub stays down and the frontend falls back to REST polling.
func (h *Hub) Run() { func (h *Hub) Run() {
for attempt := 0; attempt < hubRestartAttempts; attempt++ {
stopped := h.runOnce()
if stopped {
return
}
if attempt < hubRestartAttempts-1 {
wait := time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s
logger.Errorf("WebSocket hub crashed, restarting in %s (%d/%d)", wait, attempt+1, hubRestartAttempts-1)
select {
case <-time.After(wait):
case <-h.ctx.Done():
return
}
}
}
logger.Error("WebSocket hub stopped after exhausting restart attempts")
}
// runOnce drives the event loop once and returns true if the hub stopped
// cleanly (context cancelled). On panic, recover logs and returns false so
// Run can decide whether to retry.
func (h *Hub) runOnce() (stopped bool) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
logger.Error("WebSocket hub panic recovered:", r) logger.Errorf("WebSocket hub panic recovered: %v", r)
// Restart the hub loop stopped = false
go h.Run()
} }
}() }()
for { for {
select { select {
case <-h.ctx.Done(): case <-h.ctx.Done():
// Graceful shutdown: close all clients h.shutdown()
h.mu.Lock() return true
for client := range h.clients {
client.closeOnce.Do(func() { case c := <-h.register:
close(client.Send) if c == nil {
}) continue
} }
h.clients = make(map[*Client]bool) h.mu.Lock()
h.clients[c] = struct{}{}
n := len(h.clients)
h.mu.Unlock() h.mu.Unlock()
logger.Info("WebSocket hub stopped gracefully") logger.Debugf("WebSocket client connected: %s (total: %d)", c.ID, n)
case c := <-h.unregister:
if c == nil {
continue
}
h.removeClient(c)
case msg := <-h.broadcast:
h.fanout(msg)
}
}
}
// shutdown closes all client send channels and clears the registry.
func (h *Hub) shutdown() {
h.mu.Lock()
for c := range h.clients {
c.closeOnce.Do(func() { close(c.Send) })
}
h.clients = make(map[*Client]struct{})
h.mu.Unlock()
logger.Info("WebSocket hub stopped")
}
// removeClient deletes a client and closes its send channel exactly once.
func (h *Hub) removeClient(c *Client) {
h.mu.Lock()
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
c.closeOnce.Do(func() { close(c.Send) })
}
n := len(h.clients)
h.mu.Unlock()
logger.Debugf("WebSocket client disconnected: %s (total: %d)", c.ID, n)
}
// fanout delivers msg to every client. Each send is non-blocking — a client
// whose buffer is full is collected for direct removal at the end. We do NOT
// route slow-client unregistrations through the unregister channel: under
// burst load (panel restart, network blip) that channel can fill up while the
// hub itself is the consumer, causing a self-deadlock.
func (h *Hub) fanout(msg []byte) {
if msg == nil {
return return
case client := <-h.register:
if client == nil {
continue
} }
h.mu.Lock()
h.clients[client] = true
count := len(h.clients)
h.mu.Unlock()
logger.Debugf("WebSocket client connected: %s (total: %d)", client.ID, count)
case client := <-h.unregister:
if client == nil {
continue
}
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
client.closeOnce.Do(func() {
close(client.Send)
})
}
count := len(h.clients)
h.mu.Unlock()
logger.Debugf("WebSocket client disconnected: %s (total: %d)", client.ID, count)
case message := <-h.broadcast:
if message == nil {
continue
}
// Optimization: quickly copy client list and release lock
h.mu.RLock() h.mu.RLock()
clientCount := len(h.clients) if len(h.clients) == 0 {
if clientCount == 0 {
h.mu.RUnlock() h.mu.RUnlock()
continue return
} }
targets := make([]*Client, 0, len(h.clients))
// Pre-allocate memory for client list for c := range h.clients {
clients := make([]*Client, 0, clientCount) targets = append(targets, c)
for client := range h.clients {
clients = append(clients, client)
} }
h.mu.RUnlock() h.mu.RUnlock()
// Parallel broadcast using worker pool var dead []*Client
h.broadcastParallel(clients, message) for _, c := range targets {
} if !trySend(c, msg) {
dead = append(dead, c)
} }
} }
// broadcastParallel sends message to all clients in parallel for maximum performance if len(dead) == 0 {
func (h *Hub) broadcastParallel(clients []*Client, message []byte) {
if len(clients) == 0 {
return return
} }
h.mu.Lock()
for _, c := range dead {
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
c.closeOnce.Do(func() { close(c.Send) })
logger.Debugf("WebSocket client %s send buffer full, disconnected", c.ID)
}
}
h.mu.Unlock()
}
// For small number of clients, use simple parallel sending // trySend performs a non-blocking write to the client's Send channel.
if len(clients) < h.workerPoolSize { // Returns false if the client should be evicted (full buffer or closed channel).
var wg sync.WaitGroup // A defer-recover guards against the rare race where the channel was closed
for _, client := range clients { // concurrently — sending on a closed channel always panics, even with select+default.
wg.Add(1) func trySend(c *Client, msg []byte) (ok bool) {
go func(c *Client) {
defer wg.Done()
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
// Channel may be closed, safely ignore ok = false
logger.Debugf("WebSocket broadcast panic recovered for client %s: %v", c.ID, r)
} }
}() }()
select { select {
case c.Send <- message: case c.Send <- msg:
return true
default: default:
// Client's send buffer is full, disconnect return false
logger.Debugf("WebSocket client %s send buffer full, disconnecting", c.ID)
h.Unregister(c)
} }
}(client)
}
wg.Wait()
return
} }
// For large number of clients, use worker pool for optimal performance // Broadcast serializes payload and queues it for delivery to all clients.
clientChan := make(chan *Client, len(clients)) // If the serialized message exceeds maxMessageSize, an invalidate signal is
for _, client := range clients { // queued instead so the frontend re-fetches via REST. Broadcasts of throttled
clientChan <- client // message types (see throttledMessageTypes) within minBroadcastInterval of
} // the previous one are dropped — the next legitimate mutation will push the
close(clientChan) // fresh state.
// Use a local WaitGroup to avoid blocking hub shutdown
var wg sync.WaitGroup
wg.Add(h.workerPoolSize)
for i := 0; i < h.workerPoolSize; i++ {
go func() {
defer wg.Done()
for client := range clientChan {
func() {
defer func() {
if r := recover(); r != nil {
// Channel may be closed, safely ignore
logger.Debugf("WebSocket broadcast panic recovered for client %s: %v", client.ID, r)
}
}()
select {
case client.Send <- message:
default:
// Client's send buffer is full, disconnect
logger.Debugf("WebSocket client %s send buffer full, disconnecting", client.ID)
h.Unregister(client)
}
}()
}
}()
}
// Wait for all workers to finish
wg.Wait()
}
// Broadcast sends a message to all connected clients
func (h *Hub) Broadcast(messageType MessageType, payload any) { func (h *Hub) Broadcast(messageType MessageType, payload any) {
if h == nil { if h == nil || payload == nil || h.GetClientCount() == 0 {
return return
} }
if payload == nil { if h.shouldThrottle(messageType) {
logger.Warning("Attempted to broadcast nil payload")
return return
} }
data, err := json.Marshal(Message{
// Skip all work if no clients are connected
if h.GetClientCount() == 0 {
return
}
msg := Message{
Type: messageType, Type: messageType,
Payload: payload, Payload: payload,
Time: getCurrentTimestamp(), Time: time.Now().UnixMilli(),
} })
data, err := json.Marshal(msg)
if err != nil { if err != nil {
logger.Error("Failed to marshal WebSocket message:", err) logger.Error("WebSocket marshal failed:", err)
return return
} }
// If message exceeds size limit, send a lightweight invalidate notification
// instead of dropping it entirely — the frontend will re-fetch via REST API
const maxMessageSize = 10 * 1024 * 1024 // 10MB
if len(data) > maxMessageSize { if len(data) > maxMessageSize {
logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType) logger.Debugf("WebSocket payload %d bytes exceeds limit, sending invalidate for %s", len(data), messageType)
h.broadcastInvalidate(messageType) h.broadcastInvalidate(messageType)
return return
} }
h.enqueue(data)
}
// Non-blocking send with timeout to prevent delays // broadcastInvalidate queues a lightweight signal telling clients to re-fetch
// the named data type via REST.
func (h *Hub) broadcastInvalidate(originalType MessageType) {
data, err := json.Marshal(Message{
Type: MessageTypeInvalidate,
Payload: map[string]string{"type": string(originalType)},
Time: time.Now().UnixMilli(),
})
if err != nil {
logger.Error("WebSocket invalidate marshal failed:", err)
return
}
h.enqueue(data)
}
// enqueue submits raw bytes to the broadcast channel. Dropped on backpressure
// (channel full for >100ms) or shutdown.
func (h *Hub) enqueue(data []byte) {
select { select {
case h.broadcast <- data: case h.broadcast <- data:
case <-time.After(100 * time.Millisecond): case <-time.After(enqueueTimeout):
logger.Warning("WebSocket broadcast channel is full, dropping message") logger.Warning("WebSocket broadcast channel full, dropping message")
case <-h.ctx.Done(): case <-h.ctx.Done():
// Hub is shutting down
} }
} }
// BroadcastToTopic sends a message only to clients subscribed to the specific topic // GetClientCount returns the number of connected clients.
func (h *Hub) BroadcastToTopic(messageType MessageType, payload any) {
if h == nil {
return
}
if payload == nil {
logger.Warning("Attempted to broadcast nil payload to topic")
return
}
// Skip all work if no clients are connected
if h.GetClientCount() == 0 {
return
}
msg := Message{
Type: messageType,
Payload: payload,
Time: getCurrentTimestamp(),
}
data, err := json.Marshal(msg)
if err != nil {
logger.Error("Failed to marshal WebSocket message:", err)
return
}
// If message exceeds size limit, send a lightweight invalidate notification
const maxMessageSize = 10 * 1024 * 1024 // 10MB
if len(data) > maxMessageSize {
logger.Debugf("WebSocket message too large (%d bytes) for type %s, sending invalidate signal", len(data), messageType)
h.broadcastInvalidate(messageType)
return
}
h.mu.RLock()
// Filter clients by topics and quickly release lock
subscribedClients := make([]*Client, 0)
for client := range h.clients {
if len(client.Topics) == 0 || client.Topics[messageType] {
subscribedClients = append(subscribedClients, client)
}
}
h.mu.RUnlock()
// Parallel send to subscribed clients
if len(subscribedClients) > 0 {
h.broadcastParallel(subscribedClients, data)
}
}
// GetClientCount returns the number of connected clients
func (h *Hub) GetClientCount() int { func (h *Hub) GetClientCount() int {
if h == nil {
return 0
}
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
return len(h.clients) return len(h.clients)
} }
// Register registers a new client with the hub // Register adds a client to the hub.
func (h *Hub) Register(client *Client) { func (h *Hub) Register(c *Client) {
if h == nil || client == nil { if h == nil || c == nil {
return return
} }
select { select {
case h.register <- client: case h.register <- c:
case <-h.ctx.Done(): case <-h.ctx.Done():
// Hub is shutting down
} }
} }
// Unregister unregisters a client from the hub // Unregister removes a client from the hub. Fast path queues for the hub
func (h *Hub) Unregister(client *Client) { // goroutine; if the channel is saturated (disconnect storm) we fall back
if h == nil || client == nil { // to a direct removal under the write lock so dead clients aren't left in
// the registry waiting for their Send buffer to fill (minutes of wasted
// fanout work at low broadcast rates).
//
// Direct removal is safe from any caller: external goroutines (read/write
// pumps) hold no hub locks, and the hub goroutine itself never holds h.mu
// when it calls Unregister — fanout releases its RLock before per-client
// sends, so we can't self-deadlock here.
func (h *Hub) Unregister(c *Client) {
if h == nil || c == nil {
return return
} }
select { select {
case h.unregister <- client: case h.unregister <- c:
case <-h.ctx.Done(): default:
// Hub is shutting down h.removeClient(c)
} }
} }
// Stop gracefully stops the hub and closes all connections // Stop signals the hub to shut down and close all client connections.
func (h *Hub) Stop() { func (h *Hub) Stop() {
if h == nil { if h != nil && h.cancel != nil {
return
}
if h.cancel != nil {
h.cancel() h.cancel()
} }
} }
// broadcastInvalidate sends a lightweight invalidate message to all clients,
// telling them to re-fetch the specified data type via REST API.
// This is used when the full payload exceeds the WebSocket message size limit.
func (h *Hub) broadcastInvalidate(originalType MessageType) {
msg := Message{
Type: MessageTypeInvalidate,
Payload: map[string]string{"type": string(originalType)},
Time: getCurrentTimestamp(),
}
data, err := json.Marshal(msg)
if err != nil {
logger.Error("Failed to marshal invalidate message:", err)
return
}
// Non-blocking send with timeout
select {
case h.broadcast <- data:
case <-time.After(100 * time.Millisecond):
logger.Warning("WebSocket broadcast channel is full, dropping invalidate message")
case <-h.ctx.Done():
}
}
// getCurrentTimestamp returns current Unix timestamp in milliseconds
func getCurrentTimestamp() int64 {
return time.Now().UnixMilli()
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/web/global" "github.com/mhsanaei/3x-ui/v2/web/global"
) )
// GetHub returns the global WebSocket hub instance // GetHub returns the global WebSocket hub instance.
func GetHub() *Hub { func GetHub() *Hub {
webServer := global.GetWebServer() webServer := global.GetWebServer()
if webServer == nil { if webServer == nil {
@@ -24,80 +24,82 @@ func GetHub() *Hub {
return wsHub return wsHub
} }
// HasClients returns true if there are any WebSocket clients connected. // HasClients returns true if any WebSocket client is connected.
// Use this to skip expensive work (DB queries, serialization) when no browser is open. // Use this to skip expensive work (DB queries, serialization) when no browser is open.
func HasClients() bool { func HasClients() bool {
hub := GetHub() hub := GetHub()
if hub == nil { return hub != nil && hub.GetClientCount() > 0
return false
}
return hub.GetClientCount() > 0
} }
// BroadcastStatus broadcasts server status update to all connected clients // BroadcastStatus broadcasts server status update to all connected clients.
func BroadcastStatus(status any) { func BroadcastStatus(status any) {
hub := GetHub() if hub := GetHub(); hub != nil {
if hub != nil {
hub.Broadcast(MessageTypeStatus, status) hub.Broadcast(MessageTypeStatus, status)
} }
} }
// BroadcastTraffic broadcasts traffic statistics update to all connected clients // BroadcastTraffic broadcasts traffic statistics update to all connected clients.
func BroadcastTraffic(traffic any) { func BroadcastTraffic(traffic any) {
hub := GetHub() if hub := GetHub(); hub != nil {
if hub != nil {
hub.Broadcast(MessageTypeTraffic, traffic) hub.Broadcast(MessageTypeTraffic, traffic)
} }
} }
// BroadcastInbounds broadcasts inbounds list update to all connected clients // BroadcastClientStats broadcasts absolute per-client traffic counters for the
// clients that had activity in the latest collection window. Use this instead
// of re-broadcasting the full inbound list — it scales to 10k+ clients because
// the payload only includes active rows (typically a fraction of total).
func BroadcastClientStats(stats any) {
if hub := GetHub(); hub != nil {
hub.Broadcast(MessageTypeClientStats, stats)
}
}
// BroadcastInbounds broadcasts inbounds list update to all connected clients.
func BroadcastInbounds(inbounds any) { func BroadcastInbounds(inbounds any) {
hub := GetHub() if hub := GetHub(); hub != nil {
if hub != nil {
hub.Broadcast(MessageTypeInbounds, inbounds) hub.Broadcast(MessageTypeInbounds, inbounds)
} }
} }
// BroadcastOutbounds broadcasts outbounds list update to all connected clients // BroadcastOutbounds broadcasts outbounds list update to all connected clients.
func BroadcastOutbounds(outbounds any) { func BroadcastOutbounds(outbounds any) {
hub := GetHub() if hub := GetHub(); hub != nil {
if hub != nil {
hub.Broadcast(MessageTypeOutbounds, outbounds) hub.Broadcast(MessageTypeOutbounds, outbounds)
} }
} }
// BroadcastNotification broadcasts a system notification to all connected clients // BroadcastNotification broadcasts a system notification to all connected clients.
func BroadcastNotification(title, message, level string) { func BroadcastNotification(title, message, level string) {
hub := GetHub() hub := GetHub()
if hub != nil { if hub == nil {
notification := map[string]string{ return
}
hub.Broadcast(MessageTypeNotification, map[string]string{
"title": title, "title": title,
"message": message, "message": message,
"level": level, // info, warning, error, success "level": level,
} })
hub.Broadcast(MessageTypeNotification, notification)
}
} }
// BroadcastXrayState broadcasts Xray state change to all connected clients // BroadcastXrayState broadcasts Xray state change to all connected clients.
func BroadcastXrayState(state string, errorMsg string) { func BroadcastXrayState(state string, errorMsg string) {
hub := GetHub() hub := GetHub()
if hub != nil { if hub == nil {
stateUpdate := map[string]string{ return
}
hub.Broadcast(MessageTypeXrayState, map[string]string{
"state": state, "state": state,
"errorMsg": errorMsg, "errorMsg": errorMsg,
} })
hub.Broadcast(MessageTypeXrayState, stateUpdate)
}
} }
// BroadcastInvalidate sends a lightweight invalidate signal for the given data type, // BroadcastInvalidate sends a lightweight signal telling clients to re-fetch
// telling connected frontends to re-fetch data via REST API. // the named data type via REST. Use this when the caller already knows the
// Use this instead of BroadcastInbounds/BroadcastOutbounds when you know the payload // payload is too large to push directly (e.g., 10k+ clients) to skip the
// will be too large, to avoid wasting resources on serialization. // JSON-marshal cost on the hot path.
func BroadcastInvalidate(dataType MessageType) { func BroadcastInvalidate(dataType MessageType) {
hub := GetHub() if hub := GetHub(); hub != nil {
if hub != nil {
hub.broadcastInvalidate(dataType) hub.broadcastInvalidate(dataType)
} }
} }