Files
3x-ui/web/web.go
Sanaei cfe1b25ca0 feat(frontend): TanStack Query + React Router migration & in-panel API docs (#4541)
* feat(frontend): introduce TanStack Query with status polling

Wires @tanstack/react-query into every entry and migrates useStatus to
useStatusQuery as the foundation for the multi-page MPA → SPA migration.

- QueryProvider wraps each entry inside ThemeProvider, with devtools gated
  on import.meta.env.DEV
- Shared queryClient: 30s staleTime, refetchOnWindowFocus, 1 retry
- useStatusQuery preserves the { status, fetched, refresh } shape so
  IndexPage swaps in without further changes
- refetchIntervalInBackground:false stops the 2s status poll when the
  panel tab is hidden, cutting idle traffic against the server

* feat(frontend): collapse panel pages into a single React Router SPA

Replaces the 7-entry MPA shell (index/clients/inbounds/nodes/settings/
xray/api-docs HTML files) with one main.tsx + createBrowserRouter. The
Go backend now serves the same index.html for every authenticated
panel route; React Router reads the URL and mounts the page from cache
on subsequent navigation — no more full reloads between tabs.

Frontend
- main.tsx: single bootstrap (setupAxios, i18n, ThemeProvider,
  QueryProvider, RouterProvider) replacing 7 near-duplicate entries
- routes.tsx: declarative router with lazy()-loaded pages, basename
  derived from window.X_UI_BASE_PATH so panels at /secret/panel work
- layouts/PanelLayout.tsx: shell mount-point for the WS → queryClient
  bridge so connection survives navigation
- api/websocketBridge.ts: subscribes the singleton WebSocketClient to
  queryClient and dispatches invalidate/outbounds events to cached
  queries (page-level useWebSocket handlers stay until Phase 3 hooks
  migrate)
- AppSidebar: navigates via useNavigate + useLocation instead of
  window.location.href; drops basePath/requestUri props
- Pages: drop the unused basePath/requestUri locals exposed only for
  the old sidebar

Build
- vite.config: 9 rollup inputs → 3 (index, login, subpage). Dev proxy
  bypass collapses /panel/* to index.html and skips API prefixes
- vendor-tanstack + vendor-router chunks added to manualChunks

Backend
- xui.go: 7 per-page HTML handlers → one panelSPA handler serving
  index.html for /, /inbounds, /clients, /nodes, /settings, /xray,
  /api-docs. The /panel/api, /panel/setting, /panel/xray sub-routers
  are untouched

* feat(frontend): migrate useNodes to TanStack Query

Splits the hand-rolled useNodes hook into useNodesQuery (server data +
NodeRecord type + derived totals) and useNodeMutations (add/update/del/
setEnable/probe/test). Mutations invalidate ['nodes'] on success, so
the list refreshes without each call awaiting a manual refresh().

NodesPage drops useWebSocket({ nodes: applyNodesEvent }) — the
WebSocket → query bridge now forwards the 'nodes' push to
setQueryData(['nodes', 'list']) once at the SPA root.

InboundsPage and the inbound form/list components import NodeRecord
from its new home next to the query hook.

* feat(frontend): migrate useAllSetting to TanStack Query

Replaces the hand-rolled fetch + dirty-tracking hook with useAllSettings
backed by useQuery + useMutation. The draft (current edits) is kept in
local state and reset whenever query.data lands. saveAll posts the
draft via a mutation; on success, invalidating ['settings'] refetches
and the useEffect resets the draft so saveDisabled flips back to true.

staleTime: Infinity prevents refetchOnWindowFocus from clobbering
in-flight edits — settings only change in response to this user's own
save.

setSpinning stays as a pass-through to a local flag so the existing
restartPanel flow in SettingsPage keeps showing its spinner.

* feat(frontend): route useInbounds fetches through TanStack Query

Rewrites useInbounds so its four server fetches (slim list, default
settings, online clients, last-online map) live in useQuery with
staleTime: Infinity. The in-place WS merge logic for traffic and
client_stats is preserved — applyTrafficEvent / applyClientStatsEvent
still mutate the locally-mirrored dbInbounds so the panel doesn't
refetch every 1-2 seconds when stats stream in.

refresh() becomes a thin invalidateQueries on the three list keys,
which mutations in the page already call after add/edit/del.

The bridge now forwards the WebSocket 'inbounds' push to
setQueryData(['inbounds', 'slim']), and InboundsPage drops its
useEffect(fetchDefaultSettings → refresh) plus the invalidate /
inbounds wiring on useWebSocket — both are owned by the bridge now.

* feat(frontend): migrate useClients to TanStack Query

Replaces 12 hand-rolled mutation callbacks and a tangle of useState +
useRef + useEffect with one useQuery (paged list) + nine useMutation
wrappers. The list query uses keepPreviousData so paging/filter
changes don't blank the table mid-fetch.

The setQuery shallow-compare logic is preserved for backward
compatibility with ClientsPage's effect that rebuilds the params on
every render. Internally setQuery only updates state when the params
actually differ — Query's queryKey equality handles the rest.

WS-driven applyTrafficEvent / applyClientStatsEvent now mutate the
query cache via setQueryData(['clients', 'list', currentParams]) so
per-second stats updates skip a full refetch. applyInvalidate is gone
from the hook — the bridge owns coarse 'clients' invalidation.

ClientsPage drops the invalidate handler from its useWebSocket
subscription; auxiliary queries (inboundOptions, defaults, onlines)
load via TanStack Query and are shared with useInbounds via the same
query keys.

* feat(frontend): route useXraySetting fetches through TanStack Query

Keeps the bidirectional xraySetting ↔ templateSettings editor sync and
the 1s dirty-tracking interval intact (those are local editor state,
not server data). All seven server calls move:

- config + traffic → useQuery on ['xray', 'config'] and
  ['xray', 'outboundsTraffic']
- saveAll → useMutation that invalidates the config query
- resetOutboundsTraffic → useMutation that invalidates the traffic
  query
- restartXray → useMutation (fires the restart, then reads the
  result string)
- resetToDefault → useMutation (fetch default config, push it into
  the editor via setTemplateSettings)

The WebSocket 'outbounds' event already lands in
keys.xray.outboundsTraffic() via the bridge, so XrayPage drops its
useWebSocket({ outbounds: applyOutboundsEvent }) wiring entirely and
the hook no longer exposes applyOutboundsEvent.

A useEffect seeds xraySetting / templateSettings / tags / test URL
from query data on first fetch and on every refetch, mirroring what
the original fetchAll() did.

* fix(frontend): restore per-route document titles in the SPA

When the multi-entry MPA collapsed into a single index.html, every
route inherited the static <title>3X-UI</title> from the shared shell,
so every panel page showed "hostname - 3X-UI" instead of the original
"hostname - Overview / Clients / Inbounds / ...".

usePageTitle reads the current pathname and rewrites document.title
on every navigation, matching the titles the deleted *.html files
used to carry. Mounted in PanelLayout so it covers all panel routes
without each page having to opt in.

The startup applyDocumentTitle() call in main.tsx is gone — the hook
sets the full "hostname - PageTitle" string itself.

* feat(api-docs): expose OpenAPI spec + render Swagger UI in panel

Replaces the hand-rolled API docs UI with industry-standard tooling so
external integrations (Postman, Insomnia, openapi-generator) can
consume the panel API without parsing endpoints.js by hand.

Generator
- frontend/scripts/build-openapi.mjs: walks the existing endpoints.js
  (still the single source of truth) and emits an OpenAPI 3.0.3 spec
  at frontend/public/openapi.json. Handles Gin :param → {param} path
  translation, body / query / path parameter splits, 200 + error
  response examples, and Bearer + cookie security schemes
- npm run build now runs gen:api before vite build, so the spec is
  always in sync with what's documented

Backend
- web/controller/dist.go exposes ServeOpenAPISpec which streams the
  embedded dist/openapi.json with a short Cache-Control. Public
  endpoint (no auth) so Postman can fetch it without first logging in
- web/web.go wires GET /panel/api/openapi.json before the auth-gated
  /panel/api router

Panel
- ApiDocsPage now renders swagger-ui-react fed by the basePath-aware
  openapi.json URL. Dark mode is overridden via CSS targeting the
  Swagger UI internals
- CodeBlock / EndpointRow / EndpointSection are gone; the swagger-ui
  vendor chunk (134 KB gzipped) only loads on this lazy route, not on
  every panel page
- vite.config: vendor-swagger manualChunk keeps the new dep out of
  the main vendor bundle

For Postman: import http://<panel>/panel/api/openapi.json. Everything
from /login + /panel/api/* shows up with auth, params, and examples.

* style(api-docs): dark/ultra theme for Swagger UI

Override every visual surface Swagger does not theme on its own:
opblocks, tables, model boxes, form inputs, code blocks, modals,
Servers dropdown, per-endpoint padlocks and expand chevrons. Replaces
Swagger's default light-arrow chevron on selects with a light-fill SVG
positioned at the corner so the dark background-color is visible.

Also disables deepLinking to silence the noisy v4 underscore warning;
not used in our panel.
2026-05-24 21:34:52 +02:00

513 lines
14 KiB
Go

// Package web provides the main web server implementation for the 3x-ui panel,
// including HTTP/HTTPS serving, routing, templates, and background job scheduling.
package web
import (
"context"
"crypto/tls"
"embed"
"io"
"io/fs"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/web/controller"
"github.com/mhsanaei/3x-ui/v3/web/job"
"github.com/mhsanaei/3x-ui/v3/web/locale"
"github.com/mhsanaei/3x-ui/v3/web/middleware"
"github.com/mhsanaei/3x-ui/v3/web/network"
"github.com/mhsanaei/3x-ui/v3/web/runtime"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/web/websocket"
"github.com/gin-contrib/gzip"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/robfig/cron/v3"
)
//go:embed translation/*
var i18nFS embed.FS
// distFS embeds the Vite-built frontend (web/dist/). Every user-facing
// HTML route is served straight out of this FS — the legacy Go
// templates and `web/assets/` tree are gone post-Phase 8.
//go:embed all:dist
var distFS embed.FS
var startTime = time.Now()
// wrapDistFS adapts the embedded `dist/` directory so it can be mounted
// as the panel's `/assets/` static route. Vite emits its bundled JS/CSS
// under `dist/assets/`; serving the FS rooted at `dist/assets` makes
// `/assets/<hash>.js` URLs resolve directly.
type wrapDistFS struct {
embed.FS
}
func (f *wrapDistFS) Open(name string) (fs.File, error) {
file, err := f.FS.Open("dist/assets/" + name)
if err != nil {
return nil, err
}
return &wrapAssetsFile{
File: file,
}, nil
}
type wrapAssetsFile struct {
fs.File
}
func (f *wrapAssetsFile) Stat() (fs.FileInfo, error) {
info, err := f.File.Stat()
if err != nil {
return nil, err
}
return &wrapAssetsFileInfo{
FileInfo: info,
}, nil
}
type wrapAssetsFileInfo struct {
fs.FileInfo
}
func (f *wrapAssetsFileInfo) ModTime() time.Time {
return startTime
}
// EmbeddedDist returns the embedded Vite-built frontend filesystem.
// Controllers serve their HTML out of this FS via the dist-page handler
// installed in NewEngine().
func EmbeddedDist() embed.FS {
return distFS
}
// Server represents the main web server for the 3x-ui panel with controllers, services, and scheduled jobs.
type Server struct {
httpServer *http.Server
listener net.Listener
index *controller.IndexController
panel *controller.XUIController
api *controller.APIController
ws *controller.WebSocketController
xrayService service.XrayService
settingService service.SettingService
tgbotService service.Tgbot
customGeoService *service.CustomGeoService
wsHub *websocket.Hub
cron *cron.Cron
ctx context.Context
cancel context.CancelFunc
}
// NewServer creates a new web server instance with a cancellable context.
func NewServer() *Server {
ctx, cancel := context.WithCancel(context.Background())
return &Server{
ctx: ctx,
cancel: cancel,
}
}
func (s *Server) isDirectHTTPSConfigured() bool {
certFile, certErr := s.settingService.GetCertFile()
keyFile, keyErr := s.settingService.GetKeyFile()
if certErr != nil || keyErr != nil || certFile == "" || keyFile == "" {
return false
}
_, err := tls.LoadX509KeyPair(certFile, keyFile)
return err == nil
}
// initRouter initializes Gin, registers middleware, templates, static
// assets, controllers and returns the configured engine.
func (s *Server) initRouter() (*gin.Engine, error) {
if config.IsDebug() {
gin.SetMode(gin.DebugMode)
} else {
gin.DefaultWriter = io.Discard
gin.DefaultErrorWriter = io.Discard
gin.SetMode(gin.ReleaseMode)
}
engine := gin.Default()
directHTTPS := s.isDirectHTTPSConfigured()
sendHSTS := directHTTPS && !config.IsSkipHSTS()
engine.Use(middleware.SecurityHeadersMiddleware(sendHSTS))
webDomain, err := s.settingService.GetWebDomain()
if err != nil {
return nil, err
}
if webDomain != "" {
engine.Use(middleware.DomainValidatorMiddleware(webDomain))
}
secret, err := s.settingService.GetSecret()
if err != nil {
return nil, err
}
basePath, err := s.settingService.GetBasePath()
if err != nil {
return nil, err
}
engine.Use(gzip.Gzip(gzip.DefaultCompression))
assetsBasePath := basePath + "assets/"
store := cookie.NewStore(secret)
// Configure default session cookie options, including expiration (MaxAge)
sessionOptions := sessions.Options{
Path: basePath,
HttpOnly: true,
Secure: directHTTPS,
SameSite: http.SameSiteLaxMode,
}
if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil && sessionMaxAge > 0 {
sessionOptions.MaxAge = sessionMaxAge * 60 // minutes -> seconds
}
store.Options(sessionOptions)
engine.Use(sessions.Sessions("3x-ui", store))
engine.Use(func(c *gin.Context) {
c.Set("base_path", basePath)
})
engine.Use(func(c *gin.Context) {
uri := c.Request.RequestURI
if strings.HasPrefix(uri, assetsBasePath) {
c.Header("Cache-Control", "max-age=31536000")
}
})
// init i18n — still used by backend strings (errors, log messages,
// SubPage menu entries) even though the Go template engine is gone.
err = locale.InitLocalizer(i18nFS, &s.settingService)
if err != nil {
return nil, err
}
engine.Use(locale.LocalizerMiddleware())
// `/assets/` serves the Vite-built bundle. In dev we pull from disk
// so the Vite watcher's incremental rebuilds show up without
// restarting the binary; in prod we serve the embedded dist FS
// rooted at `dist/assets/`.
if config.IsDebug() {
engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/dist/assets")))
} else {
engine.StaticFS(basePath+"assets", http.FS(&wrapDistFS{FS: distFS}))
}
// Apply the redirect middleware (`/xui` to `/panel`)
engine.Use(middleware.RedirectMiddleware(basePath))
// Hand the embedded `dist/` filesystem to the controller package
// before any HTML-serving controller is constructed. Phase 8
// cutover: every HTML route reads from web/dist/ instead of
// rendering a legacy template.
controller.SetDistFS(distFS)
g := engine.Group(basePath)
s.index = controller.NewIndexController(g)
s.panel = controller.NewXUIController(g)
g.GET("/panel/api/openapi.json", controller.ServeOpenAPISpec)
s.api = controller.NewAPIController(g, s.customGeoService)
// Initialize WebSocket hub
s.wsHub = websocket.NewHub()
go s.wsHub.Run()
// Initialize WebSocket controller — service owns per-connection pumps,
// controller is HTTP-layer only (auth + upgrade).
s.ws = controller.NewWebSocketController(service.NewWebSocketService(s.wsHub))
// Register WebSocket route with basePath (g already has basePath prefix)
g.GET("/ws", s.ws.HandleWebSocket)
// Chrome DevTools endpoint for debugging web apps
engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
})
// Add a catch-all route to handle undefined paths and return 404
engine.NoRoute(func(c *gin.Context) {
c.AbortWithStatus(http.StatusNotFound)
})
return engine, nil
}
// startTask schedules background jobs (Xray checks, traffic jobs, cron
// jobs) which the panel relies on for periodic maintenance and monitoring.
func (s *Server) startTask(restartXray bool) {
s.customGeoService.EnsureOnStartup()
if restartXray {
err := s.xrayService.RestartXray(true)
if err != nil {
logger.Warning("start xray failed:", err)
}
}
// Check whether xray is running every second
s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob())
// Check if xray needs to be restarted every 30 seconds
s.cron.AddFunc("@every 30s", func() {
if s.xrayService.IsNeedRestartAndSetFalse() {
err := s.xrayService.RestartXray(false)
if err != nil {
logger.Error("restart xray failed:", err)
}
}
})
go func() {
time.Sleep(time.Second * 5)
s.cron.AddJob("@every 5s", job.NewXrayTrafficJob())
}()
// check client ips from log file every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
s.cron.AddJob("@every 5s", job.NewNodeHeartbeatJob())
s.cron.AddJob("@every 5s", job.NewNodeTrafficSyncJob())
// check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob())
// Inbound traffic reset jobs
// Run every hour
s.cron.AddJob("@hourly", job.NewPeriodicTrafficResetJob("hourly"))
// Run once a day, midnight
s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("daily"))
// Run once a week, midnight between Sat/Sun
s.cron.AddJob("@weekly", job.NewPeriodicTrafficResetJob("weekly"))
// Run once a month, midnight, first of month
s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
// LDAP sync scheduling
if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
runtime, err := s.settingService.GetLdapSyncCron()
if err != nil || runtime == "" {
runtime = "@every 1m"
}
j := job.NewLdapSyncJob()
// job has zero-value services with method receivers that read settings on demand
s.cron.AddJob(runtime, j)
}
// Make a traffic condition every day, 8:30
var entry cron.EntryID
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
if (err == nil) && (isTgbotenabled) {
runtime, err := s.settingService.GetTgbotRuntime()
if err != nil {
logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err)
runtime = "@daily"
} else if strings.TrimSpace(runtime) == "" {
logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily")
runtime = "@daily"
}
logger.Infof("Tg notify enabled,run at %s", runtime)
_, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob())
if err != nil {
logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
return
}
// check for Telegram bot callback query hash storage reset
s.cron.AddJob("@every 2m", job.NewCheckHashStorageJob())
// Check CPU load and alarm to TgBot if threshold passes
cpuThreshold, err := s.settingService.GetTgCpu()
if (err == nil) && (cpuThreshold > 0) {
s.cron.AddJob("@every 10s", job.NewCheckCpuJob())
}
} else {
s.cron.Remove(entry)
}
}
// Start initializes and starts the web server with configured settings, routes, and background jobs.
func (s *Server) Start() (err error) {
return s.start(true, true)
}
// StartPanelOnly initializes the panel during an in-process panel restart without cycling Xray.
func (s *Server) StartPanelOnly() (err error) {
return s.start(false, false)
}
func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
// This is an anonymous function, no function name
defer func() {
if err != nil {
s.Stop()
}
}()
loc, err := s.settingService.GetTimeLocation()
if err != nil {
return err
}
service.StartTrafficWriter()
s.cron = cron.New(cron.WithLocation(loc), cron.WithSeconds())
s.cron.Start()
// Wire the inbound-runtime manager once so InboundService can route
// add/update/delete to either the local xray or a remote node panel.
// The closures bridge into XrayService (which owns the running xray
// process state) without forcing the runtime package to import service.
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{
APIPort: func() int { return s.xrayService.GetXrayAPIPort() },
SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
}))
s.customGeoService = service.NewCustomGeoService()
engine, err := s.initRouter()
if err != nil {
return err
}
certFile, err := s.settingService.GetCertFile()
if err != nil {
return err
}
keyFile, err := s.settingService.GetKeyFile()
if err != nil {
return err
}
listen, err := s.settingService.GetListen()
if err != nil {
return err
}
port, err := s.settingService.GetPort()
if err != nil {
return err
}
listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
return err
}
if certFile != "" || keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {
c := &tls.Config{
Certificates: []tls.Certificate{cert},
}
listener = network.NewAutoHttpsListener(listener)
listener = tls.NewListener(listener, c)
logger.Info("Web server running HTTPS on", listener.Addr())
} else {
logger.Error("Error loading certificates:", err)
logger.Info("Web server running HTTP on", listener.Addr())
}
} else {
logger.Info("Web server running HTTP on", listener.Addr())
}
s.listener = listener
s.httpServer = &http.Server{
Handler: engine,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
s.httpServer.Serve(listener)
}()
s.startTask(restartXray)
if startTgBot {
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
if (err == nil) && (isTgbotenabled) {
tgBot := s.tgbotService.NewTgbot()
tgBot.Start(i18nFS)
}
}
return nil
}
// Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
func (s *Server) Stop() error {
return s.stop(true, true)
}
// StopPanelOnly stops only panel-owned HTTP/background resources for an in-process panel restart.
func (s *Server) StopPanelOnly() error {
return s.stop(false, false)
}
func (s *Server) stop(stopXray bool, stopTgBot bool) error {
s.cancel()
if stopXray {
s.xrayService.StopXray()
}
if s.cron != nil {
s.cron.Stop()
}
if stopXray {
service.StopTrafficWriter()
}
if stopTgBot && s.tgbotService.IsRunning() {
s.tgbotService.Stop()
}
// Gracefully stop WebSocket hub
if s.wsHub != nil {
s.wsHub.Stop()
}
var err1 error
var err2 error
if s.httpServer != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
err1 = s.httpServer.Shutdown(shutdownCtx)
}
if s.listener != nil {
err2 = s.listener.Close()
}
return common.Combine(err1, err2)
}
// GetCtx returns the server's context for cancellation and deadline management.
func (s *Server) GetCtx() context.Context {
return s.ctx
}
// GetCron returns the server's cron scheduler instance.
func (s *Server) GetCron() *cron.Cron {
return s.cron
}
// GetWSHub returns the WebSocket hub instance.
func (s *Server) GetWSHub() any {
return s.wsHub
}
func (s *Server) RestartXray() error {
return s.xrayService.RestartXray(true)
}