3x-ui/frontend/vite.config.js
MHSanaei f4f0af576a
feat(ws): live updates on inbounds/xray/nodes pages, drop polling + manual refresh
Replaces the legacy polling + manual-refresh model with WebSocket pushes
across the three live-data pages. The hub already broadcast traffic /
client_stats / outbounds; this wires the frontend to consume them and
adds a new `nodes` channel for the heartbeat job's snapshot.

Frontend
- new useWebSocket composable: page-scoped singleton WebSocketClient,
  lifecycle-managed on/off, leaves disconnect to page-unload
- inbounds: useInbounds gains applyTrafficEvent / applyClientStatsEvent
  / applyInvalidate that merge counters and online/lastOnline in place;
  InboundsPage subscribes; InboundList drops the auto-refresh popover,
  the refresh button, and the now-unused refreshing prop
- xray outbounds: useXraySetting gains applyOutboundsEvent; XrayPage
  subscribes; OutboundsTab drops the refresh button + emit
- nodes: useNodes gains applyNodesEvent and stops the 5s
  setInterval/visibilitychange polling; NodesPage subscribes;
  NodeList drops the refresh button and ReloadOutlined import

Backend
- web/websocket: new MessageTypeNodes + BroadcastNodes notifier
- node_heartbeat_job: after wg.Wait(), reload the table once and
  BroadcastNodes(updated). Gated on websocket.HasClients() so a panel
  with no open browser doesn't spend the DB read

Bug fixes spotted in this pass
- websocket.js #buildUrl defaulted basePath to '' when the global was
  missing (dev mode), producing `ws://host:portws` and a SyntaxError
  on the WebSocket constructor. Fall back to '/' and ensure leading
  slash.
- vite.config.js: forward /ws to ws://localhost:2053 with ws:true so
  dev (5173) reaches the Go backend's WebSocket
- NodeFormModal: a-input-password's visibilityToggle is Boolean in
  AntD Vue 4; the v3-era object form (`{ visible, 'onUpdate:visible' }`)
  triggered a Vue prop-type warning. Drop the override (default true
  shows the eye icon and toggles internally) and remove the orphaned
  tokenVisible ref

Translations
- pages.inbounds.autoRefresh / autoRefreshInterval: removed from all
  13 locales (UI gone)
- pages.nodes.refresh: removed from all 13 locales (UI gone)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:30:31 +02:00

167 lines
6.5 KiB
JavaScript

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'node:path';
// Output goes to web/dist/ at the repo root so the Go binary can embed it
// via embed.FS without reaching outside the web/ tree.
const outDir = path.resolve(__dirname, '../web/dist');
// In production the Go binary serves /panel/<route> from web/dist/<route>.html.
// In dev the Vue app lives at /index.html, /settings.html, ... while AppSidebar
// links use the production-style /panel/<route> URLs. Map each migrated route
// to its Vite entry so the sidebar works without relying on the Go backend
// for already-ported pages.
const MIGRATED_ROUTES = {
'/panel': '/index.html',
'/panel/': '/index.html',
'/panel/settings': '/settings.html',
'/panel/settings/': '/settings.html',
'/panel/inbounds': '/inbounds.html',
'/panel/inbounds/': '/inbounds.html',
'/panel/xray': '/xray.html',
'/panel/xray/': '/xray.html',
'/panel/nodes': '/nodes.html',
'/panel/nodes/': '/nodes.html',
};
// Build a proxy config that suppresses ECONNREFUSED noise when the Go
// backend isn't running locally. Real errors (timeouts, 5xx, etc.) still
// surface in the Vite log.
function makeBackendProxy(target, patterns) {
const config = {};
for (const pattern of patterns) {
config[pattern] = {
target,
changeOrigin: true,
// Returning a path from bypass tells Vite to serve that file from
// its own dev server instead of forwarding the request — used here
// to short-circuit /panel/<route> for pages we've already migrated.
//
// Only GETs get bypassed: the xray page reuses its page URL
// (`POST /panel/xray/`) for data, so a method-blind bypass would
// hand HTML back to fetch calls and break the page in dev.
bypass(req) {
if (req.method !== 'GET') return undefined;
const url = req.url.split('?')[0];
if (Object.prototype.hasOwnProperty.call(MIGRATED_ROUTES, url)) {
return MIGRATED_ROUTES[url];
}
return undefined;
},
configure(proxy) {
let warned = false;
proxy.on('error', (err, req) => {
// Node wraps connection failures in an AggregateError when DNS
// returns multiple addresses (e.g. ::1 + 127.0.0.1) and all
// refuse — the code lands on the inner errors, not the outer.
const codes = new Set();
if (err && err.code) codes.add(err.code);
if (err && Array.isArray(err.errors)) {
for (const inner of err.errors) {
if (inner && inner.code) codes.add(inner.code);
}
}
const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
if (offline) {
// Print a single friendly hint the first time, then stay quiet.
if (!warned) {
warned = true;
// eslint-disable-next-line no-console
console.warn(
`[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
);
}
return;
}
// eslint-disable-next-line no-console
console.error('[proxy]', err);
});
},
};
}
return config;
}
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
outDir,
emptyOutDir: true,
sourcemap: true,
target: 'es2020',
// ant-design-vue is intentionally bundled as one chunk (its
// components share internals — splitting it breaks Modal/Form/
// Select interop). Minified it lands ~1.4MB but gzips to ~410kB,
// so the actual transfer is fine and caches across every page.
// Bump the warning past that ceiling so the build stays quiet.
chunkSizeWarningLimit: 1500,
// Multiple HTML entries — one per legacy page we migrate.
// As pages get ported in later phases, add their entrypoints here.
rollupOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
login: path.resolve(__dirname, 'login.html'),
settings: path.resolve(__dirname, 'settings.html'),
inbounds: path.resolve(__dirname, 'inbounds.html'),
xray: path.resolve(__dirname, 'xray.html'),
nodes: path.resolve(__dirname, 'nodes.html'),
subpage: path.resolve(__dirname, 'subpage.html'),
},
output: {
// Split vendor deps into stable chunks so each page only pulls
// what it needs and the browser caches them across versions.
// Without this, ant-design-vue + vue + icons all end up in one
// 1.6MB blob attached to whichever page consumed them first.
manualChunks(id) {
if (!id.includes('node_modules')) return undefined;
if (id.includes('ant-design-vue')) return 'vendor-antd';
if (id.includes('@ant-design/icons-vue')) return 'vendor-icons';
if (id.includes('vue-i18n')) return 'vendor-i18n';
if (
id.includes('/node_modules/vue/')
|| id.includes('/node_modules/@vue/')
) return 'vendor-vue';
if (id.includes('dayjs')) return 'vendor-dayjs';
if (id.includes('qrious')) return 'vendor-qrious';
if (id.includes('axios')) return 'vendor-axios';
// The persian datepicker pulls in moment + moment-jalaali; bundle
// the trio together so unrelated pages don't pay the cost.
if (
id.includes('vue3-persian-datetime-picker')
|| id.includes('moment-jalaali')
|| id.includes('jalaali-js')
|| id.includes('/node_modules/moment/')
) return 'vendor-jalali';
return 'vendor';
},
},
},
},
server: {
port: 5173,
strictPort: true,
proxy: {
...makeBackendProxy('http://localhost:2053', [
// Patterns are anchored regex so /login.html and /index.html
// (which Vite serves itself) are NOT forwarded — only the bare
// backend paths and their sub-routes.
'^/(login|logout|getTwoFactorEnable|csrf-token)$',
'^/(panel|server)(/|$)',
]),
// The panel mounts the live-update WebSocket at /ws (basePath +
// "/ws"). Vite needs `ws: true` to forward the HTTP Upgrade to the
// Go backend; without it the dev server would 404 the upgrade and
// the page falls back to the no-data state.
'/ws': {
target: 'ws://localhost:2053',
ws: true,
changeOrigin: true,
},
},
},
});