Compare commits

..

139 Commits

Author SHA1 Message Date
MHSanaei 867a145979 feat(clients): add inbound filter + mobile page-size control
Filter bar gets an Inbound select next to Protocol — the dropdown is
narrowed to inbounds matching the chosen protocol (or shows everything
when no protocol is picked), with remark search inside the dropdown.
Choosing a protocol clears any inbound selection that no longer fits.

Server side, ClientPageParams gains an Inbound int and ListPaged runs a
clientMatchesInbound check after the protocol filter. The selection
persists in clientsFilterState localStorage alongside the existing
search/filter/protocol entries.

Mobile clients view also grows the AntD Pagination control that was
previously only on the desktop table, so page size / page navigation
are reachable from phones.
2026-05-23 23:31:41 +02:00
MHSanaei 6185db586a fix(clients): drop tombstone gate that blocked re-import after delete
ClientService.Delete tombstones a just-deleted email for 90s to keep a
late node snapshot from resurrecting it. The same check was also gating
the create branch of SyncInbound — which silently dropped clients on any
legitimate re-add (delete inbound + re-import within 90s left the
clients table empty even though settings.clients carried the rows).

The snapshot-side caller in setRemoteTraffic already filters tombstoned
emails before handing the list to SyncInbound, so removing the duplicate
check inside SyncInbound preserves the protection where it's needed and
unblocks user-initiated re-imports.

While here, mirror the addInbound shape in importInbound (NodeID=0→nil
normalisation, early return on error, broadcastInboundsUpdate) and fan
out a notifyClientsChanged from add/del/update/import so an open Clients
page picks up settings.clients reconciliation without a manual refresh.
2026-05-23 23:05:43 +02:00
MHSanaei 4c71669815 fix(clients): match by email when client identifier is stale
DBs migrated from older versions where the same email lived in
multiple inbounds with different UUIDs/passwords/auths end up with one
merged ClientRecord but each inbound's settings.clients JSON still
carries its original protocol-specific identifier. Editing such a
client through /panel/api/clients/update/:email failed with
"empty client ID" because UpdateInboundClient couldn't locate the
entry by the ClientRecord's identifier.

When the primary lookup misses, fall back to resolving the
ClientRecord by the supplied identifier and matching the inbound
entry by email. The update then proceeds and the inbound JSON
converges to the merged identifier.
2026-05-23 21:34:55 +02:00
Sanaei c6123f9628 fix(frontend): resolve lazy chunk URLs against runtime base path (#4505)
* fix(frontend): reload page on Vite chunk preload error after upgrade

After a panel upgrade the embedded dist/ ships with new hashed chunk
filenames, so SPA tabs loaded before the upgrade hold references to
chunks that no longer exist on the server and lazy modals 404. Hook
`vite:preloadError` and force one full reload (guarded by a session
flag) so the browser picks up the new index.html.

* Revert "fix(frontend): reload page on Vite chunk preload error after upgrade"

This reverts commit bf0754d21e.

* fix(frontend): resolve lazy chunk URLs against runtime base path

Vite's default chunk-preload helper prepends a hardcoded `/` to asset
filenames, so dynamic chunk preloads always 404 when the panel is
served under a non-root webBasePath (e.g. /CxuVUNgm5mRLmjPhp3/). Use
experimental.renderBuiltUrl to embed window.X_UI_BASE_PATH (injected
by dist.go) as the runtime prefix, so __vite__mapDeps emits URLs like
`<basePath>assets/<file>` regardless of where the dist is mounted.
2026-05-23 20:55:53 +02:00
MHSanaei 2ed85aadda v3.1.0 2026-05-23 19:53:15 +02:00
Sanaei b71ed1e3ee feat(bash): prompt for PostgreSQL (#4472)
* feat(install): prompt for SQLite vs PostgreSQL during install

* fix(install): write env file to per-distro path and handle pg-install failure

The env file was hardcoded to /etc/default/x-ui, but RHEL/Fedora units read
/etc/sysconfig/x-ui, Arch reads /etc/conf.d/x-ui, and Alpine OpenRC auto-
sources /etc/conf.d/x-ui. PostgreSQL selection was silently dropped on every
distro except Debian. Also initdb on openSUSE (service wouldn't start) and
prompt the operator on local-install failure instead of silently demoting
to SQLite.

* fix(scripts): make x-ui.sh and update.sh PostgreSQL-aware

update.sh ran setting -show and migrate without sourcing the env file, so
PostgreSQL users had migrations applied to the SQLite default and settings
introspection read the wrong DB. Sourcing the per-distro env file at the
start of update_x-ui exports XUI_DB_TYPE/XUI_DB_DSN to all binary calls.

x-ui.sh now shows the active backend in View Current Settings (password
masked) and removes the env file on uninstall so a later reinstall doesn't
inherit a stale DSN.
2026-05-23 19:52:37 +02:00
Sanaei 95aebf1d83 i18n: translate hardcoded inbound action + security warning strings (#4502)
The inbound row actions (delete / reset traffic / clone / export links /
export subscription links / show JSON / export-all variants) and the
security warning alert on the Settings page were emitting English text
directly. Replace them with i18n keys and add translations across all
13 supported locales.
2026-05-23 19:43:21 +02:00
Sanaei 09df07ddf5 perf(frontend): lazy-load modals + split heavy vendor chunks (#4501)
* perf(frontend): lazy-load modals on inbounds / clients / index pages

Modals on the three list pages were imported statically, so the JS +
CSS for every form, info, qr, log, backup, metrics, system-history,
version, and config-text modal sat in the initial bundle even though
they're only needed after a click.

Converted those imports to React.lazy() and gated each modal with a
new LazyMount helper that mounts on first open and keeps the component
mounted thereafter so AntD close animations still play.

Build now emits a dedicated chunk per modal — InboundFormModal at
66 kB (13 kB gzipped) and InboundInfoModal at 23 kB (4 kB gzipped)
are the largest, totalling roughly 150 kB of code that no longer
parses on first paint. Profiler measured the inbounds-page React
render tree drop from ~444 ms to ~254 ms on a prod build.

* perf(frontend): split codemirror / jalali / otpauth into lazy vendor chunks

Heavy libs (codemirror, persian-calendar-suite, otpauth) and antd's
rc-/cssinjs transitive deps used to fall into the catch-all `vendor`
chunk and load with every entry point. Give them their own manualChunks
groups so they only load with the lazy modal/page that needs them.

Initial vendor (catch-all) drops from 1293 kB / 408 kB gzip to
76 kB / 27 kB gzip; codemirror (408 kB / 131 kB gzip) is now on the
JsonEditor lazy path instead of the inbounds/clients/index initial load.
2026-05-23 18:56:11 +02:00
Sanaei c5b71041d3 Reduce list-page payloads with slim/paged endpoints (#4500)
* perf(inbounds): slim list payload + lazy hydrate for row actions

Adds GET /panel/api/inbounds/list/slim that returns the same list shape
but strips every per-client field besides email/enable/comment from
settings.clients[] and skips UUID/SubId enrichment on ClientStats.
The inbounds page only reads those three to compute its client counters
and badges, so the slim variant trims tens of bytes per client (uuid,
password, flow, security, totalGB, expiryTime, limitIp, tgId, ...).
On a panel with thousands of clients this is the dominant load-time
cost.

Detail flows (edit / info / qr / export / clone) call /get/:id through
a new hydrateInbound helper before opening — the slim list view never
needs the secrets it doesn't render.

* perf(clients): server-side pagination + slim row payload

Adds GET /panel/api/clients/list/paged that filters, sorts, and paginates
on the server, returns a slim row shape (drops uuid/password/auth/flow/
security/reverse/tgId per client), and includes a stable summary
(total, active, online[], depleted[], expiring[], deactive[]) computed
across the full DB row set so the dashboard cards don't change as the
user paginates or filters. Page size capped at 200.

useClients now exposes { clients (current page), total, filtered, query,
setQuery, summary, hydrate }. ClientsPage feeds its filter/sort/page
state into setQuery via a single effect, debounces search by 300ms, and
hydrates the full client record via /get/:email before opening edit/info/
qr modals. Local filter/sort logic and the all-clients summary memo are
gone.

On a 2000-client panel this turns the initial response from ~MB to ~25 row
slice (~10s of KB) and removes the all-client parse cost from every
refresh.

* perf(settings): use /inbounds/options for LDAP tag picker

The General settings tab only needs each inbound's tag/protocol/port to
fill a dropdown but was calling /panel/api/inbounds/list which ships the
full settings JSON with every embedded client. Switched it to /options
and added Tag to the projection. On a panel with thousands of clients
this drops the General-tab load payload from megabytes to a tiny
per-inbound row each.

* perf(clients): de-duplicate options + paged list fetches

Two issues caused each clients-page load to fire its requests twice:

1. setQuery in the hook took whatever object the consumer passed and
   stored it as-is. The consumer (ClientsPage) constructs a new object
   literal in an effect, so even when nothing actually changed the ref
   was new — the hook's useEffect saw a new query and re-fetched.
   Wrapped setQuery with a shallow value compare so identical params
   are a no-op.

2. The picker /inbounds/options fetch was bundled into refresh() with a
   length==0 guard, but the two back-to-back refreshes both saw an
   empty inbounds array (the first hadn't resolved yet) so both fired
   the request. Moved the options fetch into its own one-shot effect.

* perf(inbounds): share nodes list with form modal instead of refetching

InboundsPage and InboundFormModal both called useNodes() — each
instance maintains its own state and fires its own /panel/api/nodes/list
fetch on mount. Since the modal is always rendered (open or not), every
page load hit the endpoint twice.

Threaded nodes from the page through an availableNodes prop on the form
modal so they share one fetch.

* docs(api): register /clients/list/paged endpoint

TestAPIRoutesDocumented was failing because the new paginated clients
endpoint added in this branch wasn't listed in endpoints.js.
2026-05-23 17:43:43 +02:00
Sanaei 9c60ed7ea8 Bulk extend client expiry / traffic + clients page polish (#4499)
* chore(sub): drop unused getFallbackMaster

projectThroughFallbackMaster fully supersedes it for both
panel-tracked and legacy unix-socket fallbacks.

* feat(clients): bulk extend expiry / traffic for selected clients

Adds POST /panel/api/clients/bulkAdjust which shifts ExpiryTime by
addDays and TotalGB by addBytes for every email in one request. The
endpoint is wired into the clients page through a new ClientBulkAdjustModal
that opens from the existing multi-select toolbar.

Clients with unlimited expiry (expiryTime=0) or unlimited traffic
(totalGB=0) are skipped for the corresponding field so bulk extend
never accidentally converts an unlimited client to a limited one.
Negative values are allowed for refunds / corrections.

Translations added for all 13 locales.

* fix(db): silence GORM record-not-found spam in debug mode

getSetting handles ErrRecordNotFound via database.IsNotFound and falls
back to defaults, but GORM's Default logger still logs each miss as an
error. With periodic jobs reading unset keys (xrayTemplateConfig,
externalTrafficInformEnable) the panel log flooded thousands of times.
Switch to a logger.New with IgnoreRecordNotFoundError=true so legitimate
slow-query and SQL traces still surface in debug mode.

* fix(clients): include inboundsById in columns memo deps

Without it, the table's first paint captured an empty inboundsById and
rendered each attached inbound as #<id>. Once a sort/filter forced the
memo to rebuild it self-corrected, hence the visible flicker on reload.

* fix(clients): handle delayed-start expiry in bulk adjust

Negative ExpiryTime encodes a delay duration (magnitude = ms until
the trial begins on first use). Adding positive addDays was simply
arithmetically added, so e.g. a -7d delay + 30d turned into +23d
since epoch (1970), making the client instantly expired.

Branch on sign now: positive ExpiryTime extends additively, negative
extends by subtracting so the value stays negative (more delay).
Cross-sign reductions are skipped with an explicit reason instead of
silently corrupting the field.

* fix(clients): step traffic input by 1 GB instead of 0.1

The +/- buttons on the Total Sent/Received field nudged in 0.1 GB
increments which is too granular for typical use. Set step=1 so each
press moves a whole GB; users can still type decimal values directly.

* fix(inbounds): step Total Flow input by 1 GB instead of 0.1

Matches the same nudge fix applied to the client form's Total
Sent/Received field.
2026-05-23 16:27:20 +02:00
Sanaei edf0f36940 Frontend rewrite: React + TypeScript with AntD v6 (#4498)
* chore(frontend): add react+typescript toolchain alongside vue

Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.

* vite.config.js: react() plugin runs next to vue(); new manualChunks
  for vendor-react / vendor-antd-react / vendor-icons-react /
  vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
  rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
  allowJs: true (lets .tsx files import the remaining .js modules
  during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
  SubPageData shape consumed by the subscription page.

Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.

eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.

* refactor(frontend): port subpage to react+ts

Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.

Introduces two shared pieces both entries (and future ones) will
use:

* src/hooks/useTheme.tsx — React Context + useTheme hook + the
  same buildAntdThemeConfig (dark/ultra-dark token overrides) and
  pauseAnimationsUntilLeave helper the vue version exposes. Same
  localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
  side effects (body.className, html[data-theme]) so the two stay
  in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
  the same web/translation/*.json files via import.meta.glob. The
  vue-i18n setup in src/i18n/index.js is untouched and still serves
  the remaining vue entries.

SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.

* refactor(frontend): port login to react+ts

Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.

* LoginPage.tsx: same form fields, conditional 2FA input,
  rotating headline ("Hello" / "Welcome to..."), drifting blob
  background, theme cycle + language popover. Headline transition
  switches from vue's <Transition mode=out-in> to a CSS keyframe
  animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
  from the vue entry — both are framework-agnostic in src/utils
  and src/api/axios-init.js.

useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.

* refactor(frontend): port api-docs to react+ts

Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.

Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.

Notable transformations:

* The scroll observer that highlights the active TOC link is a
  useEffect keyed on sections — re-registers whenever the visible
  set changes (search filter narrows it). Same behaviour as the vue
  watchEffect.
* v-html="safeInlineHtml(...)" becomes
  dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
  helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
  the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
  signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
  step 1. Drawer + Sider keyed off the same localStorage flag
  (isSidebarCollapsed) and DOM theme attributes the vue version
  uses, so the two stay in sync during coexistence.

* refactor(frontend): port nodes to react+ts

Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.

New shared pieces (live alongside their .vue counterparts during
coexistence):

* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
  and unsubscribes on unmount. The underlying client is a single
  module-level instance so multiple components on the same page
  share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
  the totals memo (online/offline/avgLatency) used by the summary card.
  applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
  suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
  axis scaling, gradient fill, tooltip overlay, and per-instance
  gradient id from React.useId. ResizeObserver lifecycle is in
  useEffect; the math is unchanged.

Pages:

* NodesPage — wires hooks + WebSocket together, renders summary card
  + NodeList, hosts the form modal. Uses Modal.useModal() for the
  delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
  mobile flips to a vertical card list whose actions live in a
  bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
  per change). The reset-on-open effect computes the next state
  once and applies it with eslint-disable to satisfy the new
  react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
  {bucket} every 15s, renders cpu+mem sparklines side-by-side.

* refactor(frontend): port settings to react+ts

Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.

* models/setting.ts — typed AllSetting class with the same field
  defaults and equals() behavior the vue version had. The .js
  twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
  exposes updateSetting(patch), saveDisabled is derived via useMemo
  off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
  of named slots. The vue twin stays alive because xray (BasicsTab,
  DnsTab) still imports it; deleted when xray migrates.

The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.

SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.

SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.

* refactor(frontend): port clients to react+ts

Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).

New shared infra (lives alongside vue twins until inbounds migrates):

* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
  attach/detach + traffic reset, with WebSocket event handlers
  (traffic, client_stats, invalidate) and a small debounced refresh
  on the invalidate event. State managed via setState; the live
  client_stats event merges traffic snapshots row-by-row through a
  ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
  with subscribe/notify so multiple components can read the panel's
  Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
  vue3-persian-datetime-picker has no React port; the Jalali UI
  calendar is deferred (read-only Jalali display via IntlUtil
  formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
  shared between clients (qr modal) and inbounds (still on vue).
  Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
  the clients form needs. The full inbound model stays as
  inbound.js for now; inbounds will pull it in as inbound.ts.

The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.

The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.

ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.

* refactor(frontend): port index dashboard to react+ts

Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.

* refactor(frontend): port xray to react+ts

Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.

The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.

Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.

* Upgrade frontend deps (antd v6, i18n, TS)

Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.

* refactor(frontend): port inbounds to react+ts and drop vue toolchain

Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.

Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.

Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.

* chore(frontend): adopt antd v6 api updates

Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop

Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.

* chore(frontend): antd v6 polish, theme + modal fixes

- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
  inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
  imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
  html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
  actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
  parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
  ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
  to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments

* fix(frontend): qr code collapse — open only first panel, allow toggle

ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.

Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).

* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash

- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
  hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
  the form mounts with saved values instead of relying on a useEffect
  sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
  pointerdown closure so addEventListener/removeEventListener match;
  drag state lives on a ref (from/to/moved) so onUp reads the real
  indices, not stale closure values. Adds setPointerCapture so Windows
  and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
  switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
  treated undefined as "not empty" which crashed VLESSSettings.fromJson
  when json.vnext was missing

* fix(frontend): clipboard reliability + restyle login page

- ClipboardManager.copyText: prefer navigator.clipboard on secure
  contexts, fall back to a focused on-screen textarea + execCommand.
  Old path used left:-9999px which failed selection in some browsers
  and swallowed execCommand's return value, so the "copied" toast
  appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
  glassmorphic card (backdrop-filter blur + saturate), gradient brand
  text/accent, masked grid texture for depth, and a thin gradient
  border on the card. Light/dark/ultra each get their own palette.

* Memoize compactAdvancedJson and update deps

Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.

* style(frontend): prettier charts, drop redundant frame, format net rates

- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
  dashed grid, glowing pulse on the latest-point marker, pill-shaped
  tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
  monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
  inner wrapper I'd added stacked a second border on top); strip the
  decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
  formatter

* style(frontend): refined dark/ultra palette + shared pro card frame

- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
  sidebar/header #15161a (recessed nav, darker than cards), card
  #23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
  the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
  all seven content pages (index, clients, inbounds, xray, settings,
  nodes, api-docs) share one definition instead of duplicating in each
  page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
  larger 17px stat values, subtle gradient divider between stat columns,
  ellipsis on action labels so "Backup & Restore" doesn't break the
  card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards

* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep

- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
  values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
  fails on a fresh clone with an empty dist directory

* docs: refresh frontend docs for the React + TS + AntD 6 stack

Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:

- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout

* style(frontend): improve readability and mobile polish

- bump statistic title/value contrast in dark and ultra-dark so totals
  on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
  Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
  the Xray version tag pattern, and turn it orange when an update is
  available
- make the login settings button a proper circle by adding size="large"
  + an explicit border-radius fallback on .toolbar-btn

* feat: jalali calendar support and date formatting fixes

- Wire useDatepicker into IntlUtil and switch jalalian display locale
  to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
  "AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
  light/dark/ultra theme map and CSS overrides so the inline-styled
  input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
  tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
  render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
  previously only the inbound settings JSON was bumped, so the panel
  never saw a fresh updated_at after editing a client

* feat(frontend): donate link, panel version label, login lang menu

- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales

* fix(xray-update): respect XUI_BIN_FOLDER on Windows

The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.

* Bump Xray to v26.5.9 and minor cleanup

Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.

* fix(frontend): route remaining copy buttons through ClipboardManager

Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.

* fix(db): store CreatedAt/UpdatedAt in milliseconds

GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.

* Improve legacy clipboard copy handling

Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.

* fix(lint): drop redundant ok=false in clipboard fallback catch

* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
2026-05-23 15:21:45 +02:00
MHSanaei 237b7c898d Bump frontend deps: vue and vite
Update frontend dependencies to pull in recent patch fixes and compatibility updates. package.json bumps vue from ^3.5.13 to ^3.5.34 and vite from ^8.0.11 to 8.0.13. package-lock.json updated accordingly (including postcss 8.5.14 → 8.5.15 and nanoid ^3.3.11 → ^3.3.12).
2026-05-21 20:39:07 +02:00
MHSanaei 7368359924 fix(xray): resolve relative log paths under panel log folder
Rewrite relative `log.access`/`log.error` values in the Xray config to
absolute paths under config.GetLogFolder() so Xray writes log files
alongside the panel's logs regardless of the panel's working directory.
Absolute paths, empty/"none" values, and nested relative paths are left
untouched.
2026-05-21 19:15:24 +02:00
MHSanaei f2f5d584b3 fix(frontend): stack form fields on mobile in client/inbound/node modals
Replace fixed :span values with responsive :xs="24" :md="N" so form rows
collapse to a single column on narrow viewports instead of squeezing.
2026-05-21 18:54:42 +02:00
MHSanaei 3d1d75d65a Revert "build(deps-dev): bump vite from 8.0.13 to 8.0.14 in /frontend (#4487)"
this version of vite have issue
2026-05-21 16:35:33 +02:00
Cheng Ho Ming, Eric 6e2816d035 fix(frontend): override browser default background color on autofilled login inputs (#4478) 2026-05-21 16:24:54 +02:00
dependabot[bot] 7fc7c14ac1 build(deps-dev): bump vite from 8.0.13 to 8.0.14 in /frontend (#4487)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.13 to 8.0.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 15:59:40 +02:00
githacs2022 5f318f3b16 Add SockOpt.Mark and SockOpt.Interface parameters for Outbound stream (#4480) 2026-05-20 22:02:46 +02:00
MHSanaei 9f80cfedab fix(sub): use standard sub://BASE64#REMARK scheme for Shadowrocket 2026-05-19 18:09:51 +02:00
MHSanaei 1b436bb3e0 fix(clients): honor global pageSize and widen size-changer dropdown
Read pageSize from defaultSettings and apply it to the clients table so
the panel-wide pagination preference is respected. Widen the AntD
size-changer trigger and its teleported popup so '100 / page' no longer
truncates.
2026-05-19 17:02:34 +02:00
MHSanaei 5b5ac3f04b fix(migrate): include hysteria, hysteria2, shadowsocks in client sync
The MigrationRequirements protocol filter only covered vmess/vless/trojan,
so orphaned clients in hysteria/hysteria2/shadowsocks inbounds were never
synced into the relational clients table on startup.
2026-05-19 17:02:26 +02:00
MHSanaei 3827d7d061 fix(clients): seed all clients when settings.clients has string tgId
The ClientsTable seeder unmarshaled each settings.clients entry into
model.Client and silently `continue`d on error. Older inbounds wrote
tgId as an empty string for every client past the first; that fails to
unmarshal into int64, so only the first client per inbound landed in
the new clients table.

Normalize tgId and the other int64/int fields on the raw map before
marshal+unmarshal: parseable strings convert, empty/unparseable ones
drop so the field falls back to zero. Also log on the residual
unmarshal-failure path so the next regression is visible.

Recover already-seeded installs by re-syncing each inbound's clients
into the relational tables from MigrationRequirements, so running
`x-ui migrate` heals partial seeds.
2026-05-19 16:10:57 +02:00
MHSanaei d7f47d8b6a fix(xray): allow private-IP destinations via freedom finalRules
Xray-core v26.4.17 added a default policy that blocks private IPs in the
freedom outbound for vless/vmess/trojan/hysteria/wireguard inbounds,
even when the panel's routing rules send traffic to direct (#4420). The
legacy ipsBlocked override was deprecated in the same release.

Default template now seeds the direct outbound with a finalRules entry
that explicitly allows geoip:private, so users who intentionally remove
the geoip:private->blocked routing rule actually regain LAN access.
Defense in depth is preserved: the routing rule still blocks private
IPs by default, so unmodified configs keep the same behavior.

OutboundFormModal exposes a Final Rules editor under the Freedom
section: per-rule action (allow/block), network, port, IP/CIDR/geoip
tags, and an optional blockDelay for block actions.
2026-05-19 15:42:16 +02:00
Abdalrahman fd3770c8c9 fix: parse XHTTP extra fields from V2Ray links and v2rayN JSON imports (#4426)
- fromVmessLink: parse all XHTTP bidirectional fields (xPaddingBytes,
  xPaddingObfsMode, session/seq/uplink placements & keys, scMaxEachPostBytes,
  headers) from VMess share link JSON
- fromParamLink: parse same missing fields from the extra JSON param in
  VLESS/Trojan/SS share links and from URL params
- VLESSSettings.fromJson: handle v2rayN-style nested vnext array for
  address/port/id/flow/encryption; previously only flat format was accepted
- StreamSettings.fromJson: accept splithttpSettings as backward-compat
  alias for xhttpSettings, normalize splithttp network to xhttp

Closes #4406

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-19 15:00:15 +02:00
Константин 758e1ad050 Make HSTS policy configurable if https is enabled (#4462)
* Make HSTS policy configurable if https is enabled

* refactor(web): gate HSTS at call site so XUI_SKIP_HSTS doesn't drop the Secure cookie flag

isDirectHTTPSConfigured was being reused for both the HSTS middleware and
the session cookie's Secure flag (web.go:185). Embedding the env-var
check inside it meant setting XUI_SKIP_HSTS=true also stripped Secure
from session cookies on a real HTTPS server. Split the concerns: keep
isDirectHTTPSConfigured honest (cert/key only) and combine it with the
env var at the call site for the HSTS middleware only.

---------

Co-authored-by: Konstantin Kayukin <t_kkayukin@admarketplace.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-19 14:28:05 +02:00
Black 121b6e0bd0 feat(panel): copy connection strings for mixed inbound (#4450)
* feat(panel): copy connection strings for `mixed` inbound

* feat(panel): inline share buttons on desktop, dropdown on mobile

Replace the credentials-copy dropdown with three labeled share buttons
(SOCKS5 / HTTP / Telegram), each with a tooltip preview of the full URL.
Reverse the URI auth position so the format becomes
`scheme://host:port@user:pass` (matches Hiddify-style sharing). Add a
Telegram t.me/socks link with URL-encoded user/pass.

On viewports <=600px the inline row collapses into a single Copy
dropdown to keep the per-account row from wrapping into clutter. RTL
panels are unaffected — the share divider uses inline-* logical props.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-19 14:15:10 +02:00
MHSanaei bb5ea3af05 revert install.sh 2026-05-19 13:09:35 +02:00
MHSanaei b36e5e0869 fix(security): redact at source and cap marshal sizes for CodeQL
CodeQL kept flagging the merge logger because taint flowed Password ->
ClientMergeConflict.Old -> log even with a runtime redact helper -- the
analyzer can't prove the branch excludes credentials. Redact at the
source instead: uuid/password/auth/subId now only ever land in the
conflict struct as <redacted> placeholders, so no caller (log or
otherwise) can leak them.

For the ClientWithAttachments marshal overflow alert, replace the
MaxInt-len() arithmetic with explicit per-input size caps (256MB each),
which is the pattern CodeQL's own docs recommend and recognizes.
2026-05-19 12:48:01 +02:00
MHSanaei 788c979ad1 fix(client): guard against int overflow in ClientWithAttachments marshal
CodeQL flagged go/allocation-size-overflow on len(rec)+len(extra) feeding
make's capacity. Not exploitable in practice (both come from json.Marshal
of bounded structs), but add an explicit MaxInt guard to silence the
analyzer and make the precondition obvious.
2026-05-19 12:40:18 +02:00
MHSanaei 66f946ee54 fix(db): redact credentials in client-merge conflict logs
CodeQL flagged go/clear-text-logging: the merge conflict logger printed
raw Old/New/Kept values, which for password/auth/uuid/subId fields meant
credentials landed in plain-text logs. Mask those four fields at the log
site so operators still see which field collided without leaking secrets.
2026-05-19 12:40:11 +02:00
MHSanaei 6000bc7134 fix(websocket): order register/unregister via single ops channel
Two separate channels under one select gave Go's randomness the chance
to process an unregister before its matching register from the same
goroutine, leaking the entry into the client map. Replace with a single
ordered ops channel so program order is preserved end-to-end.
2026-05-19 12:34:53 +02:00
Sanaei 85e2ded0e1 Feat/multi inbound clients (#4469)
* feat(clients): add shadow tables for first-class client promotion

Introduces three new GORM-backed tables (clients, client_inbounds,
inbound_fallback_children) and a populate-only seeder that backfills
them from each inbound's existing settings.clients JSON. Duplicate
emails across inbounds auto-merge under one client row, with each
field conflict logged. Existing services are unchanged and continue
reading from settings.clients — this commit is groundwork only.

* feat(clients): make clients+client_inbounds the runtime source of truth

Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.

GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.

* feat(clients): add top-level Clients tab and CRUD API

Adds /panel/api/clients endpoints (list, get, add, update, del,
attach, detach) backed by ClientService methods that orchestrate
the per-inbound Add/Update/Del flows so a single client row is
created once and attached to many inbounds in one operation.

The frontend gains a dedicated Clients page (frontend/clients.html
+ src/pages/clients/) with an AntD table, multi-inbound attach
modal, and full CRUD. Axios interceptor learns to honour
Content-Type: application/json so the JSON endpoints work
alongside the legacy form-encoded ones.

The legacy per-inbound client modal stays untouched in this PR —
both flows now write to the same source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds): add Port-with-Fallback inbound type

Adds a new "portfallback" protocol that emits as a VLESS-TLS inbound
under the hood but is paired with a sidecar table of child inbounds.
Panel auto-builds settings.fallbacks at Xray-config-gen time from the
sidecar — each child's listen+port becomes the fallback dest, with
SNI/ALPN/path/xver match criteria pulled from the row. No more typing
loopback ports by hand or keeping settings.fallbacks in sync.

Backend: new FallbackService (Get/SetChildren, BuildFallbacksJSON);
two new routes (GET/POST /panel/api/inbounds/:id/fallbackChildren);
xray.GetXrayConfig injects fallbacks for PortFallback inbounds; the
inbound model emits protocol="vless" so Xray accepts the config.

Frontend: PORTFALLBACK joins the protocol dropdown; selecting it
shows the standard VLESS controls plus a Fallback Children table
(inbound picker + per-row SNI/ALPN/path/xver). Children are loaded
on edit and replaced atomically on save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Reset Traffic, QR Code, Info actions + Online/Remaining columns

The Clients page table gains:
- Online column — green/grey tag driven by /panel/api/inbounds/onlines,
  polled every 10s.
- Remaining column — bytes-remaining tag, coloured green/orange/red
  against quota, purple infinity when unlimited.
- Action icons per row: QR, Info, Reset traffic, Edit, Delete.

ClientInfoModal shows the full client detail (uuid/password/auth,
traffic ↑/↓ + remaining + all-time, expiry absolute + relative,
attached inbounds chip list, online + last-online).

ClientQrModal fetches links for the client's subId via
/panel/api/inbounds/getSubLinks/:subId and renders each one through
the existing QrPanel component.

Reset Traffic confirms then calls the existing per-inbound endpoint
on the client's first attached inbound (the traffic row is keyed on
email globally, so any attached inbound resets the shared counter).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): expose Attached inbounds in edit mode

The multi-select was gated on add-only, so editing a client had no way
to change which inbounds it belonged to. The picker now shows in both
modes, and on submit the modal diffs the picked set against the
original attachedIds — additions go through the /attach endpoint,
removals through /detach, both after the field update lands so the
new attachments get the latest values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): unbreak template parsing + stale i18n keys

- InboundFormModal: split the multi-line help string in the
  PortFallback section onto one line — Vue's template parser was
  bailing on Unterminated string constant because a single-quoted
  literal spanned two lines inside a {{ }} interpolation.
- ClientInfoModal: t('disable') was missing at the root level, so
  vue-i18n returned the key path literally. Use t('disabled') which
  exists.
- Linter cleanup elsewhere: pages.client.* references renamed to
  pages.clients.* to match the merged i18n block; whitespace
  normalisation in a few unrelated Vue templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* 1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(traffic): drop all-time traffic tracking

Removes the AllTime field from Inbound and ClientTraffic and migrates
existing DBs by dropping the all_time columns on startup. The counter
duplicated up+down without adding signal, and the per-event accumulator
ran on every traffic write.

Frontend: drop the All-time column from the inbound list and the
client-row table, the All-time row from the client info modal, and the
All-Time Total Usage tile from the inbounds summary card. The
allTimeTraffic/allTimeTrafficUsage i18n keys are removed across every
locale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): mobile cards, multi-select, bulk add

Adds the same row-card layout the inbounds page uses on mobile: the
table is suppressed under the mobile breakpoint and each client renders
as a compact card with a status dot, email, Info button, Enable switch,
and overflow menu. All the per-client detail (traffic, remaining,
expiry, attached inbounds, flow, created/updated, URL, subscription)
opens through the existing info modal.

Multi-select with bulk delete wires AntD row-selection on desktop and
a per-card checkbox on mobile; a Delete (N) button appears in the
toolbar when anything is selected.

Bulk add reuses the five email-generation modes from the inbound bulk
modal but takes a multi-inbound picker so one bulk run can attach to
several inbounds at once. Submits client-by-client through the
existing /panel/api/clients/add endpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(inbounds): remove legacy per-inbound client UI

Now that clients live as first-class rows attached to one or many
inbounds, the per-inbound client UI on the inbounds page is dead
weight — every client action either has a global equivalent on the
Clients page or makes no sense in a many-to-many world.

Deletes ClientFormModal, ClientBulkModal, CopyClientsModal, and
ClientRowTable from inbounds/. Strips the matching emits, refs,
handlers, and dropdown menu items from InboundList and InboundsPage,
and removes the dead mobile expand-chevron state and the desktop
expanded-row plumbing that drove the inline client table.

The InboundFormModal Clients tab still works in add-mode (one inline
client at inbound creation) — that flow goes through ClientService.
SyncInbound on save and remains useful.

Fixes a stray "</a-dropdown>" left over by an earlier toolbar edit
in ClientsPage that broke the template parser.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Delete depleted action

Mirrors the legacy delDepletedClients action that lived under the
inbounds page, but as a first-class /panel/api/clients/delDepleted
endpoint backed by ClientService. The new path goes through
ClientService.Delete for each depleted email, so the new clients +
client_inbounds + xray_client_traffic tables stay consistent.

Adds a danger-styled toolbar button on the Clients page (next to
Reset all client traffic) with a confirm dialog and a toast
reporting the deleted count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(api): move every client-shaped endpoint off /inbounds onto /clients

After the multi-inbound client migration, client state belongs to the
client API surface, not the inbound one. Twelve routes that were
crammed under /panel/api/inbounds/* now live where they belong, under
/panel/api/clients/*.

Moved (route, handler, doc):
  POST  /clientIps/:email
  POST  /clearClientIps/:email
  POST  /onlines
  POST  /lastOnline
  POST  /updateClientTraffic/:email
  POST  /resetAllClientTraffics/:id
  POST  /delDepletedClients/:id
  POST  /:id/resetClientTraffic/:email
  GET   /getClientTraffics/:email
  GET   /getClientTrafficsById/:id
  GET   /getSubLinks/:subId
  GET   /getClientLinks/:id/:email

Their /clients/* counterparts are:
  POST  /clients/clientIps/:email
  POST  /clients/clearClientIps/:email
  POST  /clients/onlines
  POST  /clients/lastOnline
  POST  /clients/updateTraffic/:email
  POST  /clients/resetTraffic/:email          (email-only, fans out)
  GET   /clients/traffic/:email
  GET   /clients/traffic/byId/:id
  GET   /clients/subLinks/:subId
  GET   /clients/links/:id/:email

per-inbound resetAllClientTraffics and delDepletedClients are dropped
entirely — the Clients page already exposes global Reset All Traffic
and Delete depleted actions, and per-inbound resets are meaningless
once a client can be attached to many inbounds.

ClientService.ResetTrafficByEmail is the new email-only reset path:
it looks up every inbound the client is attached to and pushes the
counter reset + Xray re-add through inboundService.ResetClientTraffic
for each one, so depleted users come back online instantly.

Frontend callers (ClientsPage, useClients, ClientQrModal,
ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all
switched to the new paths. The Inbounds page drops its per-inbound
"Reset client traffic" and "Delete depleted clients" dropdown items —
users do those at the client level now. api-docs is rebuilt to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(service): switch tgbot + ldap callers to ClientService

Adds two thin helpers to ClientService (CreateOne, DetachByEmail) and
rewrites tgbot.SubmitAddClient and ldap_sync_job to call ClientService
directly. Removes the JSON-blob payloads (BuildJSONForProtocol output for
add, clientsToJSON/clientToJSON helpers) that callers previously fed to
InboundService.AddInboundClient/DelInboundClient.

ldap_sync_job.batchSetEnable now loops InboundService.SetClientEnableByEmail
per email instead of trying to coerce AddInboundClient into doing the
update — the old path would have failed duplicate-email validation for
existing clients anyway.

The legacy InboundService.AddInboundClient/UpdateInboundClient/
DelInboundClient methods stay in place; they are now only used internally
by ClientService Create/Update/Delete/Attach. Inlining + deleting them
follows in a separate commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(service): move all client mutation methods to ClientService

Moves the client mutation surface out of InboundService and into
ClientService. These methods all operate on a single client (identity
fields, traffic limits, expiry, ip limit, enable state, telegram tg id)
and didn't belong on the inbound aggregate.

Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID,
checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail,
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail,
ResetClientTrafficLimitByEmail.

Each method now takes an explicit *InboundService for the helpers that
legitimately stay on InboundService (GetInbound, GetClients, runtimeFor,
AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs /
UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs,
GetClientInboundByEmail / GetClientInboundByTrafficID,
GetClientTrafficByEmail).

Stays on InboundService: ResetClientTrafficByEmail and
ResetClientTraffic(id, email) — these mutate xray_client_traffic rows,
not client identity, so they're inbound-side bookkeeping.

Callers updated: tgbot (6 calls), ldap_sync_job (1 call),
InboundService internal (writeBackClientSubID, CopyInboundClients,
AddInbound's email-uniqueness check), ClientService Create/Update/
Delete/Attach/Detach.

Also removes a dead resetAllClientTraffics controller handler whose
route was already gone after the previous /clients API migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(clients): finish migrating to ClientService + tidy IP routes

Two related cleanups in the new /clients surface:

1. Move ResetAllClientTraffics (bulk-reset of xray_client_traffic +
   last_traffic_reset_time, with node-runtime propagation) from
   InboundService to ClientService. PeriodicTrafficResetJob now holds
   a clientService and calls
   j.clientService.ResetAllClientTraffics(&j.inboundService, id).
   The last client-mutation method on InboundService is gone.

2. Shorten redundantly-named routes/handlers under /panel/api/clients:
   - /clientIps/:email      -> /ips/:email      (handler getIps)
   - /clearClientIps/:email -> /clearIps/:email (handler clearIps)
   The "client" prefix was redundant inside the clients namespace.

Frontend (InboundInfoModal) and api-docs updated to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds,clients): clean up inbound modal + enrich client modal

Inbound modal rework (InboundFormModal.vue + inbound.js):
- Drop the embedded Client subform in the Protocol tab. Multi-inbound
  clients are managed exclusively from the Clients page now; a fresh
  inbound is created with zero clients (settings constructors default
  to []) and the user attaches clients afterwards.
- Hide the Protocol tab entirely when it has nothing to render
  (VMESS, Trojan without fallbacks, Hysteria). Auto-switches active
  tab to Basic when the tab disappears while focused.
- Move the Security section (Security selector + TLS block with certs
  and ECH + Reality block) out of the Stream tab into its own
  Security tab, sharing the canEnableStream gate.

Client modal additions (ClientFormModal.vue + ClientBulkAddModal.vue):
- Flow select (xtls-rprx-vision / -udp443) appears only when the
  panel actually has a Vision-capable inbound (VLESS or PortFallback
  on TCP with TLS or Reality). Hidden otherwise, and cleared when
  it disappears.
- IP Limit input is disabled when the panel-level ipLimitEnable
  setting is off, fetched into useClients alongside subSettings and
  threaded through ClientsPage to both modals.
- Edit modal now shows an "IP Log" section listing IPs that have
  connected with the client's credentials, with refresh and clear
  buttons (calls the renamed /panel/api/clients/ips and /clearIps
  endpoints).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(inbounds): drop manual Fallbacks UI from inbound modal

The PortFallback protocol type now covers the common
VLESS-master-plus-children case with auto-wired dests, so the manual
Fallbacks editor (showFallbacks block in the Protocol tab) is mostly
redundant. Removed:

- the v-if="showFallbacks" template block (SNI/ALPN/Path/dest/PROXY rows)
- the showFallbacks computed
- the addFallback / delFallback helpers
- the .fallbacks-header / .fallbacks-title styles
- the showFallbacks gate from hasProtocolTabContent (so Trojan-over-TCP
  no longer shows an empty Protocol tab)

Power users who need a non-inbound fallback dest (nginx, static site)
can still author settings.fallbacks via the Advanced JSON tab.

* feat(clients,inbounds): move search/filter to Clients page + small fixes

Search/filter relocation:
- Remove the search/filter toolbar (search switch + filter radio +
  protocol/node selects + the visibleInbounds projection +
  inboundsFilterState localStorage + filter CSS + the SearchOutlined/
  FilterOutlined/ObjectUtil/Inbound imports it required) from
  InboundList. The filters were all client-oriented buckets bolted
  onto the inbound row.
- Add a search/filter toolbar to ClientsPage with the same shape:
  switch between deep-text search and bucket filter (active /
  deactive / depleted / expiring / online) + protocol filter that
  matches clients attached to at least one inbound with the chosen
  protocol. State persists in clientsFilterState localStorage.
  filteredClients drives both the desktop table and the mobile card
  list, and select-all / allSelected / someSelected only span the
  visible subset.
- useClients now also fetches expireDiff and trafficDiff from
  /panel/setting/defaultSettings (used to detect the expiring
  bucket); ClientsPage threads them into the client-bucket helper.

Loose fixes folded in:
- Add Client: email field is auto-filled with a random handle on
  open, matching uuid/subId/password/auth.
- Inbound clone: parse and reuse the source settings JSON (with
  clients reset to []) instead of building a fresh defaulted
  Settings, so VLESS Encryption/Decryption and other non-client
  fields survive the clone.
- en-US.json: add the ipLog string used by the edit-client modal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): add Reverse tag field for VLESS-attached clients

Mirrors the Flow field's pattern: a Reverse tag input appears in the
Add/Edit Client modal whenever at least one selected inbound is VLESS
or PortFallback. The value rides over the wire as
client.reverse = { tag: '...' } so it lands directly in model.Client's
*ClientReverse field; an empty value omits the reverse key entirely.

On edit the field is hydrated from props.client.reverse?.tag, and the
showReverseTag watcher clears the field if the user drops the last
VLESS-like inbound from the selection.

* fix(xray): emit only protocol-relevant fields per client entry

The Xray config synthesizer was writing every identifier field (id,
password, flow, auth, security/method, reverse) on every client entry
regardless of the inbound's protocol. Xray ignores unknown fields, so
the config worked, but it diverged from the spec and leaked secrets
across protocols when one client was attached to multiple inbounds —
a VLESS inbound's generated config carried the same client's Trojan
password and Hysteria auth alongside its uuid.

Switch on inbound.Protocol when building each entry:
- VLESS / PortFallback: id, flow, reverse
- VMess: id, security
- Trojan: password, flow
- Shadowsocks: password, method
- Hysteria / Hysteria2: auth
email is emitted for every protocol.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): restore auto-disable kick under new schema

disableInvalidClients still resolved (inbound_tag, email) pairs via
JSON_EACH(inbounds.settings.clients), which is empty after migrating
to the clients + client_inbounds tables. Result: xrayApi.RemoveUser
never ran for depleted clients, clients.enable stayed true so the UI
showed them as active, and only xray_client_traffic.enable got flipped
- making "Restart Xray After Auto Disable" only half-work.

Resolve the targets via a JOIN through the new schema, flip clients.enable
so the Clients page reflects the state, and drop the legacy JSON
write-back plus the subId cascade workaround (email is unique now).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): live WebSocket updates + Ended status surfacing

ClientsPage now subscribes to traffic / client_stats / invalidate
WebSocket events instead of polling /onlines every 10s. Per-row
traffic counters refresh in place, online state stays current, and
list-level mutations elsewhere trigger a refresh.

The client roll-up summary moves from InboundsPage to ClientsPage
where it belongs, restructured into six labeled stat tiles
(Total / Online / Ended / Expiring / Disabled / Active) with email
popovers on the ones with issues.

Auto-disabled clients (traffic exhausted or expiry passed) now
classify as 'depleted' even though clients.enable=false, so they
show up under the Ended filter and render a red Ended tag instead
of looking indistinguishable from an operator-disabled row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(nodes): per-node client roll-up and panel version

Added transient inboundCount / clientCount / onlineCount /
depletedCount fields to model.Node, populated by NodeService.GetAll
via aggregated queries (one join across inbounds + client_inbounds,
one over client_traffics intersected with the in-memory online
emails). The Nodes list renders these as colored chips on a new
"Clients" column so an operator can see at a glance how many users
each node carries and how many are currently online or depleted.

Also exposes the remote panel's version. The central panel adds
panelVersion to its /api/server/status payload (sourced from
config.GetVersion). Probe reads that field and persists it on the
node row, mirroring how xrayVersion already flows. NodesPage gets
a new column next to Xray Version, in both desktop and mobile
views, with English and Persian strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): stop node sync from resurrecting deleted clients

Several related issues around node-managed clients:

- Remote runtime: drop the per-inbound resetAllClientTraffics path
  and point traffic/onlines/lastOnline fetches at the new
  /panel/api/clients/* routes.
- Delete from master: always push the updated inbound to the node
  even when the client was already disabled or depleted, so the
  node actually loses the user instead of silently keeping it.
- setRemoteTraffic: mirror remote clients into the central tables
  only on first discovery of a node inbound. Matched inbounds let
  the master own the join table, so a stale snap can no longer
  re-create a ClientRecord (and join row) for a client that was
  just deleted on the master.
- ClientService.Delete: route through submitTrafficWrite so deletes
  serialize with node traffic merges, and switch the final
  ClientRecord delete to an explicit Where("id = ?") clause.
- setRemoteTraffic UNIQUE-constraint fix: use clause.OnConflict on
  inserts and email-keyed UPDATEs for client_traffics, so mirroring
  a snap doesn't trip the unique email index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(clients): switch client API endpoints from id to email

All client-scoped routes now use the unique email as the path key
(get, update, del, attach, detach, links). Email is the stable,
protocol-independent identifier — UUIDs don't exist for trojan or
shadowsocks, and internal numeric ids leaked panel implementation
detail into the public API.

Removed the redundant /traffic/byId/:id endpoint (covered by
/traffic/:email) and collapsed /links/:id/:email into /links/:email,
which now returns links across every attached inbound for the client.

Frontend selection, bulk delete, and toggle state are now keyed by
email as well, dropping the id→email lookup workaround.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(server): move cached state and helpers into ServerService

ServerController had grown to hold its own status cache, version-list
TTL cache, history-bucket whitelist, and the loop that drove all three
— concerns that belong in the service layer. Pull them out:

- lastStatus + the @2s refresh become ServerService.RefreshStatus and
  ServerService.LastStatus; the controller's cron now just orchestrates
  the cross-service side effects (xrayMetrics sample, websocket broadcast).
- The 15-minute Xray-versions cache (with stale-on-error fallback) moves
  into ServerService.GetXrayVersionsCached, collapsing the controller
  handler to a single call.
- The freedom/blackhole outbound-tag walk used by /xraylogs becomes
  ServerService.GetDefaultLogOutboundTags.
- The allowed-history-bucket whitelist moves to package-level
  service.IsAllowedHistoryBucket, so both NodeController and
  ServerController validate against the same list.

Net result: web/controller/server.go drops from 458 to 365 lines and
contains only HTTP wiring + presentation-y side effects.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(api): emit JSON-text columns as nested objects

Inbound, ClientRecord, and InboundClientIps store settings /
streamSettings / sniffing / reverse / ips as JSON-text in the DB. The
API was passing that text through verbatim, so every consumer had to
JSON.parse a string inside a string. Add MarshalJSON / UnmarshalJSON so
the wire format is a real nested object, while still accepting the
legacy escaped-string shape on write. Frontend dbinbound.js gets a
matching coerceInboundJsonField helper for the same dual-shape read
path, and inbound.js toJson stops emitting empty/placeholder fields
(externalProxy [], sniffing destOverride when disabled, etc.) so the
new normalised JSON stays terse. api-docs and the inbound-clone path
are updated to the new shape. Controller route lists are regrouped so
all GETs sit above POSTs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): include inboundIds and traffic in /clients/list

ClientRecord got its own MarshalJSON in the previous commit, and
ClientWithAttachments embeds it to add inboundIds and traffic. Go
promotes the embedded MarshalJSON to the outer struct, so the encoder
was calling ClientRecord.MarshalJSON for the whole value and silently
dropping the extras. The frontend reads row.inboundIds / row.traffic
from /clients/list, so attached inbounds didn't render and newly added
clients looked like they hadn't saved. Add an explicit MarshalJSON on
ClientWithAttachments that splices the extras in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): gate IP Log on ipLimitEnable + clean access-log dropdown

Legacy panel hid the IP Log section when access logging was off; the
Vue 3 migration left it gated on isEdit only, so the section showed
even when xray's access log was 'none' and nothing was being recorded.
Restore the ipLimitEnable gate on the edit modal's IP Log form-item.

While here, clean up the Xray Settings access-log dropdown: previously
two 'none' entries appeared (an empty value labelled with t('none') and
the literal 'none' from the options array). Drop the empty option for
access log (the literal 'none' covers it) and relabel the empty option
for error log / mask address to t('empty') so they're distinguishable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(nodes): route per-client ops through node clients API + orphan sweep

Adds Runtime methods AddClient, UpdateUser, and DeleteUser so master
mutates clients on a node via /panel/api/clients/{add,update,del} rather
than pushing the whole inbound. The previous rt.UpdateInbound path made
the node DelInbound+AddInbound on every single-client change, briefly
cycling every other user on the same inbound.

DelInbound no longer filters by enable=true, so a disabled node inbound
actually gets removed from the node instead of being resurrected by the
next snap.

setRemoteTrafficLocked now sweeps any ClientRecord with zero
ClientInbound rows after SyncInbound rebuilds the attachments, which is
how a node-side delete propagates back to master instead of leaving a
detached ghost. ClientService.Delete tombstones the email first so a
snap arriving mid-delete can't re-create the record.

WebSocket broadcasts an "invalidate(clients)" message on every client
mutation so the Clients page refreshes without manual reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(balancers): allow fallback on all strategies + feed burstObservatory from random/roundRobin

Drops the random/roundRobin gate on the Fallback field in
BalancerFormModal so every strategy can pick a fallback outbound.

syncObservatories now feeds burstObservatory from leastLoad +
random + roundRobin balancers (was leastLoad only), matching how
leastPing feeds observatory.

Fix the JsonEditor "Unexpected end of JSON input" that appeared
when switching a balancer between leastPing and another strategy:
the obsView watcher was gated on showObsEditor (a boolean OR of
the two flags) and missed the case where one observatory
swapped for the other in the same tick. Watch the individual
flags instead so obsView flips to the surviving editor and the
getter stops pointing at a deleted key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(inbounds): use sortedInbounds for mobile empty-state check

InboundList referenced an undefined visibleInbounds in the mobile
card list's empty-state guard, throwing "Cannot read properties of
undefined (reading 'length')" and breaking the entire mobile render.

* feat(clients): sortable table columns

Adds the same sortState / sortableCol / sortFns pattern InboundList
uses, wrapping filteredClients in sortedClients so sort composes with
the existing search/filter pipeline. Sortable: enable, email,
inboundIds (attachment count), traffic, remaining, expiryTime;
actions and online stay unsorted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(shadowsocks): generate valid ss2022 keys and per-client method for legacy ciphers

The Add Client flow on shadowsocks inbounds was producing xray configs
that failed to start:

- 2022-blake3-* ciphers need a base64-encoded key of an exact byte
  length per cipher. fillProtocolDefaults was assigning a uuid-style
  string, which xray rejects as "bad key". Now the password is
  generated (or replaced if invalid) via random.Base64Bytes(n) sized
  to the chosen cipher.
- Legacy ciphers (aes-256-gcm, chacha20-*, xchacha20-*) require a
  per-client method field in multi-user mode; model.Client has no
  Method, so settings.clients was stored without one and xray failed
  with "unsupported cipher method:". applyShadowsocksClientMethod
  now injects the top-level method into each client on add/update,
  and healShadowsocksClientMethods backfills it at xray-config-build
  time so existing inbounds heal on the next start.
- xray/api.go ssCipherType switch was missing aes-256-gcm, which
  fell through to ss2022 path.
- SSMethods dropdown now offers aes-256-gcm.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): preserve ClientRecord on inbound delete + filter Attached inbounds to multi-client protocols

Replace the global orphan sweep in setRemoteTrafficLocked with a
per-inbound diff cleanup: only delete a ClientRecord whose email
disappeared from a snap-tracked inbound (i.e. a node-side delete).
Inbounds that vanished entirely from the snap (e.g. admin deleted
the inbound on master) aren't iterated, so a client whose last
attachment came from that inbound is now left alone instead of
being deleted alongside the inbound.

ClientFormModal and ClientBulkAddModal now filter the Attached
inbounds dropdown to protocols that actually support multiple
clients: shadowsocks, vless, vmess, trojan, hysteria, hysteria2,
and portfallback (which routes through VLESS settings).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): make empty-state text readable on dark/ultra themes

The "No clients yet" empty state had a hardcoded black color
(rgba(0,0,0,0.45)) that vanished against the dark backgrounds.
Drop the inline color, let it inherit from the AntD theme, and
fade with opacity like the mobile card empty state already does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(clients): client-first tgbot add flow, tgId field, lightweight inbound options

- tgbot: drop legacy per-protocol Add Client UI in favour of a client-first
  multi-inbound flow. New BuildClientDraftMessage / getInboundsAttachPicker
  let an admin pick one or more inbounds and submit a single client; per-
  protocol secrets are now generated server-side via fillProtocolDefaults.
  Drops awaiting_id/awaiting_password_tr/awaiting_password_sh state cases
  and add_client_ch_default_id/pass_tr/pass_sh/flow callbacks. Adds a
  setTGUser button + awaiting_tg_id state so the bot can set Client.TgID
  during Add.
- clients UI: add Telegram user ID input to ClientFormModal (0 = none).
  Hide IP Limit field entirely when ipLimitEnable is off — disabled fields
  still take layout space, this collapses Auth(Hysteria) to full width.
- inbounds API: new GET /panel/api/inbounds/options that returns just
  {id, remark, protocol, port, tlsFlowCapable}. Used by the clients page
  pickers so the dropdown payload stays small on panels with thousands of
  clients (drops settings JSON, clientStats, streamSettings). Server-side
  TlsFlowCapable mirrors Inbound.canEnableTlsFlow so the modal no longer
  needs to parse streamSettings client-side.
- clientInfoMsg now shows attached inbound remarks, and getInboundUsages
  reports the attached client count per inbound.
- api-docs: document the new /options endpoint and add tgId / flow to the
  clients add/update bodies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(inbounds): keep Node column visible for node-attached inbounds

The Node column was bound to hasActiveNode, so disabling every node hid
the column even when inbounds were still attached to those nodes — the
admin lost the visual cue that those inbounds belonged to a node and
would come back when it was re-enabled. Combine hasActiveNode with a
new hasNodeAttachedInbound check (any dbInbound with nodeId != null) so
the column survives node-disable.

* fix(api-docs): accept functional-component icons in EndpointSection

AntD-Vue icons (SafetyCertificateOutlined, etc.) are functional
components, so the icon prop's type: Object validator was rejecting
them with a "Expected Object, got Function" warning at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: cover crypto, random, netsafe, sub helpers, xray equals, websocket hub, node service

Adds ~110 unit tests across previously untested packages. Focus on
pure-logic and concurrency surfaces where regressions would silently
affect users:

- util/crypto, util/random: password hashing round-trip, ss2022 key
  generation, alphabet/length invariants.
- util/netsafe: IsBlockedIP edge cases, NormalizeHost validation,
  SSRF guard with AllowPrivate context bypass.
- util/common, util/json_util: traffic formatter, Combine nil-skip,
  RawMessage empty-as-null and copy-on-unmarshal.
- sub: splitLinkLines, searchKey/searchHost, kcp share fields,
  finalmask normalization, buildVmessLink round-trip.
- xray: Config.Equals and InboundConfig.Equals field-by-field,
  getRequiredUserString/getOptionalUserString type checks.
- web/websocket: hub registration, throttling, slow-client eviction,
  nil-receiver safety, concurrent register/unregister.
- web/service: NodeService.normalize validation, normalizeBasePath,
  HeartbeatPatch.ToUI mapping.
- web/job: atomicBool concurrent set/takeAndReset semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n(clients): replace English fallbacks with proper translation keys

Pulls every hard-coded English label/title in the Clients page and its
four modals through the i18n layer so localized panels stop leaking
English. New keys live under pages.clients (auth, hysteriaAuth, uuid,
flow, flowNone, reverseTag, reverseTagPlaceholder, telegramId,
telegramIdPlaceholder, created, updated, ipLimit) plus refresh at the
root and toasts.bulkDeletedMixed / bulkCreatedMixed for partial-failure
toasts. Also switches the add-client modal's primary button from "Add"
to "Create" for consistency with other create flows.

The bulk-add Random/Random+Prefix/... email-method options stay
hard-coded by request - they're identifier-shaped strings.

* i18n: backfill 99 missing keys across all 12 non-English locales

Brings every translation file up to parity with en-US.json so the
Clients page, the fallback-children inbound section, the new refresh
verb, the Nodes panel-version label and a handful of older holes stop
falling through to the English fallback. New strings span:

- pages.clients.* (labels, confirmations, toasts, emailMethods)
- pages.inbounds.portFallback.* (Reality fallback inbound section)
- pages.nodes.panelVersion, menu.clients, refresh

Technical identifiers (Auth, UUID, Flow, Reverse tag) are intentionally
left untranslated since they correspond to xray-core field names.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* i18n: drop stale pages.client block duplicated in every non-English locale

Every non-English locale carried a pages.client (singular) section with
30 entries that duplicated pages.clients (plural). The plural namespace
is what the Vue code actually consumes; the singular one was dead
weight from an older rename that never got cleaned up in the
non-English files. Removing it brings every locale to exactly 984
keys, matching en-US.json.

* chore: apply modernize analyzer fixes across codebase

Mechanical replacements suggested by golang.org/x/tools/.../modernize:
strings.Cut/CutPrefix/SplitSeq, slices.Contains, maps.Copy, min(),
range-over-int, new(expr), strings.Builder for hot += loops,
reflect.TypeFor[T](), sync.WaitGroup.Go(), drop legacy //+build lines.

* feat(database): add PostgreSQL as an optional backend alongside SQLite

Lets operators with large client counts or multi-node setups pick PostgreSQL
at install time without breaking the existing SQLite default. Backend is
selected at runtime via XUI_DB_TYPE/XUI_DB_DSN, a small dialect layer keeps
the five JSON_EXTRACT/JSON_EACH queries portable, and a new `x-ui migrate-db`
subcommand copies SQLite data into PostgreSQL in FK-aware order.

* fix(inbounds): gate node selector to multi-node-capable protocols

Hide the Deploy-To selector and clear nodeId when switching to a
protocol that can't run on a remote node. Also:

- subs: return 404 (not 400) when subId matches no inbounds, so VPN
  clients distinguish "deleted/unknown" from a server error
- hysteria link gen: use the inbound's resolved address so node-managed
  inbounds advertise the node host instead of the central panel
- shadowsocks: default network to 'tcp' (udp was causing issues for some
  clients on first-create)
- vite dev proxy: rewrite migrated-route bypass against the live base
  path instead of a hardcoded single-segment regex

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form

Bulk add/delete were serial on the frontend (one toast per call, N round-trips)
and the backend race exposed by parallelizing them lost client attachments and
hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also
had no Start-After-First-Use option, and the table never showed the delayed
duration.

Backend (web/service/client.go):
- Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on
  the same inbound don't lose the read-modify-write of settings JSON.
- SyncInbound skips create+join when the email is tombstoned so a concurrent
  maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn-
  Settings) that did a stale RMW can't resurrect a just-deleted client with a
  fresh id.
- compactOrphans sweeps settings.clients entries whose ClientRecord no longer
  exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each
  user-initiated mutation self-heals the inbound's settings.
- DelInboundClient uses Pluck instead of First for the stats lookup so a
  missing row doesn't abort the delete with a noisy ErrRecordNotFound log.

Frontend:
- HttpUtil.{get,post} accept a silent option that suppresses the auto-toast.
- ClientBulkAddModal fires creates in parallel + silent + one summary toast.
- useClients.removeMany runs deletes in parallel + silent and refreshes once;
  ClientsPage bulk delete uses it and shows one aggregate toast.
- useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket
  invalidate events from the backend collapses into a single refresh.
- ClientsPage pagination is reactive (paginationState ref + tablePagination
  computed); onTableChange persists page-size and page changes.
- ClientFormModal gains a Start-After-First-Use switch + Duration days input
  alongside the existing Expiry Date picker; on edit-mode open a negative
  expiryTime is decoded back to delayed mode + days; on submit the payload
  sends -86400000 * days or the absolute timestamp.
- ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip
  Start After First Use: Nd) instead of infinity.
- Telegram ID field in the form is hidden when /panel/setting/defaultSettings
  reports tgBotEnable=false; Comment then fills the row.
- Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4)
  when ipLimitEnable is on, else UUID + Total GB at 12/12.
- useInbounds.rollupClients counts only clients with a matching clientStats
  row, so orphans in settings.clients no longer inflate the inbound's count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(windows): clean shutdown, working panel restart, harden kernel32 load

Three Windows-specific issues addressed:

1. Orphaned xray-windows-amd64 after VS Code debugger stop. Delve's
   "Stop" sends TerminateProcess to the Go binary, which is uncatchable
   — our signal handlers never run, so xrayService.StopXray() is skipped
   and xray is left dangling. Spawn xray as a child of a Job Object with
   JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so the OS kills xray when our
   handle to the job is closed (which happens even on TerminateProcess).
   Also trap os.Interrupt in main so Ctrl+C in the terminal runs the
   graceful path.

2. /panel/setting/restartPanel logged "failed to send SIGHUP signal: not
   supported by windows" because Windows can't deliver arbitrary signals.
   Add a restart hook in web/global; main registers it to push SIGHUP
   into its own signal channel, and RestartPanel calls the hook before
   falling back to the (Unix-only) signal path. Same restart-loop code
   runs in both cases.

3. util/sys/sys_windows.go now uses windows.NewLazySystemDLL so the
   kernel32.dll resolve is pinned to %SystemRoot%\System32 (prevents
   DLL hijacking by a planted DLL next to the binary). Local filetime
   type replaced with windows.Filetime, and the unreliable
   syscall.GetLastError() fallback replaced with a type assertion on the
   errno captured at call time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sys): correct CPU/connection accounting on linux + darwin

util/sys/sys_linux.go:
- GetTCPCount/GetUDPCount were counting the column header row in
  /proc/net/{tcp,udp}[6] as a connection, inflating the reported total
  by 1 per non-empty file (so the panel status line always showed 2
  more connections than actually existed). Replace getLinesNum +
  safeGetLinesNum with a single bufio.Scanner-based countConnections
  that skips the header.
- CPUPercentRaw now opens HostProc("stat") instead of a hardcoded
  /proc/stat so HOST_PROC overrides apply, matching the connection
  counters in the same file.
- Simplify CPU field unpacking: pad nums to 8 once instead of guarding
  every assignment with a len check.

util/sys/sys_darwin.go:
- Fix swapped idle/intr indices on kern.cp_time. BSD CPUSTATES order
  is user, nice, sys, intr, idle (CP_INTR=3, CP_IDLE=4) — gopsutil's
  cpu_darwin_nocgo.go reads the same layout. The previous code used
  out[3] as idle and out[4] as intr, so busy = total - dIdle was
  actually subtracting interrupt time, making the panel report CPU
  usage close to 100% on macOS regardless of actual load.
- Collapse the per-field delta math into a single loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(xray): rotate crash reports into log folder, prevent overwrites

writeCrashReport had two flaws: it wrote to the bin folder (alongside the
xray binary) which conflates artifacts, and the second-precision timestamp
meant a tight restart-loop crash burst overwrote prior reports. Write to
the log folder with nanosecond precision and keep the last 10 reports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* revert(inbounds): drop unreleased portfallback protocol

The Port-with-Fallback inbound (commit 62fd9f9d) was confusing as a
standalone protocol — fallbacks belong on a regular VLESS/Trojan TCP-TLS
inbound, the way Xray models them natively. Rip out the entire feature
cleanly (no migration needed since it was never released): protocol
constant, fallback children DB table, FallbackService, 2 API endpoints,
all UI rows, related translations and api-docs. A native fallback flow
attached to VLESS/Trojan TCP-TLS/Reality will land in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(inbounds): native fallbacks on VLESS/Trojan TCP-TLS, with working child links

A VLESS or Trojan inbound on TCP with TLS or Reality can now act as a
fallback master: pick existing inbounds as children and the panel auto-
fills the SNI / ALPN / path / xver routing fields from each child's
transport, auto-builds settings.fallbacks at config-gen time, and
rewrites the child's client-share link so it advertises the master's
reachable endpoint and TLS state instead of the child's loopback listen.

Layout matches the Xray All-in-One Nginx example: master at :443 with
clients + TLS, each child on 127.0.0.1 with its own transport+clients.
Order matters (Xray walks fallbacks top-to-bottom) — reorder via the
per-row up/down arrows. Path / SNI / ALPN are exposed under a per-row
Edit toggle for the rare cases where the auto-derivation needs
overriding; otherwise just pick a child and you're done.

Backend: new InboundFallback table + FallbackService (GetByMaster /
SetByMaster / GetParentForChild / BuildFallbacksJSON); two routes
(GET / POST /panel/api/inbounds/:id/fallbacks); xray.GetXrayConfig
injects settings.fallbacks for any VLESS/Trojan TCP-TLS/Reality
inbound; GetInbounds annotates each child with FallbackParent so the
frontend can rewrite links without an extra round-trip.

Link projection covers every emission path — clients-page QR/links,
per-inbound Get URL, raw subscription, sub-JSON, sub-Clash, and the
inbounds-page link/info/QR — via a shared projectThroughFallbackMaster
on the backend and a shared projectChildThroughMaster on the frontend
that both handle the panel-tracked relationship and the legacy
unix-socket (@vless-ws) convention.

Strings translated into all 12 non-English locales.

* docs: rewrite CONTRIBUTING with full local-dev setup

The prior three-line CONTRIBUTING left newcomers guessing at every
non-trivial step: which Go / Node versions, where xray comes from, why
the panel goes blank when XUI_DEBUG=true is flipped on, how the Vue
multi-page setup is wired, what to do on Windows when go build trips
on the CGo SQLite driver.

Now covers prerequisites, MinGW-w64 install on Windows (niXman builds
or MSYS2), one-shot first-time setup, two frontend dev workflows with
the XUI_DEBUG asset-cache gotcha called out, the architecture and
conventions of the Vue side, a project-layout map, useful env vars,
and the PR checklist.

---------
2026-05-19 12:20:24 +02:00
Komar f9ae0347c6 fix(translation): correct typos and improve phrasing in English localization (#4430) 2026-05-16 10:24:04 +02:00
MHSanaei 2928b52b04 feat(tgbot): add Flow picker when creating a VLESS client
The bot's add-client flow already serialised client_Flow into the VLESS
JSON template but never exposed a way to set it from Telegram, so every
client ended up with an empty flow regardless of the inbound's transport.

Added an inline "Flow" row to the VLESS protocol keyboard with three
choices — None, xtls-rprx-vision, and xtls-rprx-vision-udp443 — and a
matching i18n key in all 13 locale files. The row is only shown when
the inbound can actually use Vision flow (mirrors the frontend's
canEnableTlsFlow check: VLESS over TCP with TLS or Reality); on other
transports it's hidden and any stale client_Flow value is reset, so the
generated JSON stays consistent with the inbound's stream settings.
2026-05-15 13:12:54 +02:00
MHSanaei 07cdb82027 fix(inbounds): don't delete remote inbound when toggling enable
SetInboundEnable called rt.DelInbound for every runtime, but Remote.DelInbound
hits panel/api/inbounds/del/:id on the node — a real row delete, not just a
"stop serving" hint like Local.DelInbound. Flipping the enable switch on a
remote inbound therefore wiped the row on the node entirely.

Route remote inbounds through UpdateInbound instead so the row stays and only
the enable flag is patched. Local path keeps the Del+Add flow since that's
how Xray's gRPC API expects to be driven.

Fixes #4402
2026-05-15 12:43:16 +02:00
MHSanaei f00f82b392 fix(outbound): probe UDP-based outbounds over UDP instead of TCP
The fast-probe mode hard-coded net.DialTimeout("tcp", ...), so testing a
WARP/WireGuard or Hysteria outbound always failed with an i/o timeout —
those transports only listen on UDP, never on TCP.

Probe is now transport-aware: extractOutboundEndpoints tags each endpoint
with the network the proxy actually listens on (UDP for wireguard,
hysteria, and any outbound whose streamSettings.network is hysteria, kcp,
or quic; TCP otherwise). probeUDPEndpoint dials UDP, writes a single
sentinel byte so the kernel can surface ICMP errors, and treats a read
timeout as success (WireGuard ignores invalid packets, so silence is the
expected reply from a reachable server). The result's mode field now
reflects what was probed, so the UI badge shows UDP for these outbounds
instead of mislabelling them as TCP.
2026-05-15 12:29:53 +02:00
MHSanaei 5a1019534f refactor(inbounds): tighten advanced JSON helpers and fix dark-mode subtitles
Collapsed repeated stream/sniffing/settings handling in InboundFormModal
into shared helpers (stampAdvancedTextFor, parseAdvancedSliceWithLabel,
compactAdvancedJson, withSaving) plus a wrapped-config factory for the
single-key editors. Cuts ~120 lines from the script section with no
behavior change.

The advanced-panel subtitle and editor-meta text used a fixed dark color
that was unreadable on the dark and ultra-dark modal backgrounds.
Switched both to opacity-on-inherit so they pick up AntD's theme-aware
foreground color, the same pattern .section-heading already uses.
2026-05-15 12:12:47 +02:00
Abdalrahman 78f1719c6d fix: prevent online clients from randomly disappearing from panel UI (#4387)
* fix: prevent online clients from randomly disappearing from panel UI

Online status was determined solely by whether a client transferred
bytes in the current 5-second polling window. The online list was
completely replaced each cycle, so idle-but-connected clients with no
traffic delta in that window were dropped from the UI.

Now online status is computed from lastOnline DB timestamps with a
5-second grace period via RefreshOnlineClientsFromMap(), so clients
remain visible across idle polling windows.

Closes #4384

* fix: extend online client grace period to survive idle poll cycles

The 5s grace period equalled the traffic-poll interval, so a client
whose Xray stats reported a zero delta for one cycle was still dropped
on the very next tick. Bump to 20s (~4 polls) so idle-but-connected
sessions stay visible across momentary counter gaps without lingering
long after a real disconnect.

Refs #4384

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-05-15 11:41:29 +02:00
MHSanaei 5cf8a08540 fix: disable balancer fallbackTag for random / roundRobin strategies
Xray-core's RandomStrategy and RoundRobinStrategy register a pending
dependency on the Observatory feature whenever fallbackTag is non-empty.
Since the panel only provisions observatory for leastPing / leastLoad
balancers, picking roundRobin with a fallbackTag caused xray to fail
boot with "not all dependencies are resolved". Disable the fallback
field for the two strategies that cannot resolve it, and strip
fallbackTag from the wire balancer as a defensive backstop for users
who edit the JSON template directly.
2026-05-15 11:24:50 +02:00
MHSanaei 79a9be7b22 fix: split locale chunks by removing eager i18n glob
The eager `import.meta.glob` was statically pulling all 13 locale JSON
files into the main bundle, defeating the sibling lazy glob and emitting
INEFFECTIVE_DYNAMIC_IMPORT warnings. Statically import only the en-US
fallback, lazy-load the rest, and await `readyI18n()` in each entry
before mount so the first paint still uses the active locale.
2026-05-15 10:50:40 +02:00
Abdalrahman 19d50bd16c fix: add i18n translations for Allow private address node option across all locales (#4386)
* fix: add Chinese locale translations for Allow private address node option

* fix: add Allow private address translations to all remaining locale files
2026-05-15 09:51:14 +02:00
MHSanaei 3af45c1462 fix: Add base-path meta tag for Cloudflare Rocket Loader compatibility
When Cloudflare Rocket Loader is enabled, it interferes with inline scripts that set window.X_UI_BASE_PATH, causing the frontend to fail to configure the correct base URL for API calls. This results in 404 errors on the login page when calling /getTwoFactorEnable.

Solution: Add meta name='base-path' tag to HTML (similar to csrf-token), update axios initialization to read from meta tag as fallback. Meta tags are not affected by CSP or Rocket Loader delays.

Fixes #4393
2026-05-14 23:37:25 +02:00
MHSanaei 6badd829df Remove streamSettings for protocols that don't support it
- Frontend: Only include streamSettings in toJson() for vmess, vless, trojan, shadowsocks, and hysteria protocols
- Frontend: Hide Stream tab in Advanced section for unsupported protocols
- Frontend: Clear streamSettings in Advanced tab when switching to unsupported protocols
- Frontend: Add CodeMirror JSON editor to config view in index page with mobile responsive design
- Backend: Add normalizeStreamSettings() to clear streamSettings for tunnel, mixed, http, tun, and wireguard protocols
- Backend: Apply normalization in AddInbound() and UpdateInbound()
- Backend: Add omitempty JSON tag to StreamSettings field to exclude null values from Xray config
2026-05-14 23:18:23 +02:00
MHSanaei b79abc8bc9 refactor: remove legacy advancedJson state 2026-05-14 20:32:38 +02:00
MHSanaei 05b68c3b13 fix: remove Auth password
#4388
2026-05-14 19:28:09 +02:00
Abdalrahman f3c7660f84 fix: correct Hysteria2 Obfs password label to Auth password (#4388)
The Obfs password field in the Hysteria2 stream settings tab was incorrectly
labeled. It binds to hysteriaSettings.auth (the server-wide authentication
password), not to the salamander obfuscation password. Per Xray-core docs,
Hysteria2 salamander obfuscation belongs in finalmask.udp[].salamander.password,
which is correctly handled by the FinalMaskForm (UDP Masks section).

Fixed the label to Auth password with an accurate tooltip explaining that
salamander obfuscation is configured via the UDP Masks section below.
2026-05-14 18:53:04 +02:00
MHSanaei 9b0fd047cb fix: guard certificate and key against undefined before join 2026-05-14 17:46:24 +02:00
MHSanaei e4218a1029 feat: click QR to copy/save image instead of link text 2026-05-14 17:40:40 +02:00
Fedor Batonogov 7065d41be6 docs(readme): add Community Tools section (#4114)
3x-ui has a growing ecosystem of community tools (Terraform, scripts,
exporters, etc.). This adds a Community Tools section between
Acknowledgment and Support project in all 6 localized READMEs so users
can discover them from the main project page.

The format mirrors the existing Acknowledgment section so future
maintainers of 3x-ui-related tools can extend it with one-line PRs.
2026-05-14 15:54:52 +02:00
MHSanaei 1284756f8a fix(outbound): restore TLS, QUIC params and TCP masks when importing share links
- fromHysteriaLink: parse security= URL param and populate stream.tls
  (SNI, fingerprint, ALPN, ECH) when security=tls; previously always
  forced security to 'none'
- fromHysteriaLink: parse fm JSON param and populate both
  stream.finalmask.quicParams (drives the QUIC Params toggle in
  FinalMaskForm) and the mirrored stream.hysteria fields
- fromParamLink (VLESS/Trojan/SS): parse fm JSON param and restore
  stream.finalmask (TCP masks, UDP masks, QUIC params)
- fromVmessLink (VMess): same fm handling for the base64-JSON path

Closes #4376
2026-05-14 13:27:55 +02:00
MHSanaei 1f052c0e8f fix: preserve TLS cert file paths when deploying inbound to remote node
When creating a Hysteria (or any TLS-required) inbound from the central
panel and deploying it to a remote node, sanitizeStreamSettingsForRemote
was unconditionally stripping certificateFile / keyFile from the TLS
settings. This left Xray on the remote node with a TLS block containing
no certificate, causing Xray to crash and the inbounds page to hang.

The fix: only strip cert file paths when inline certificate content
(certificate / key arrays) is also present in the same entry — those
file paths are then truly redundant. When only file paths are present
the user explicitly entered paths that live on the remote node's
filesystem; they are now passed through untouched.

Fixes #4370
2026-05-14 12:41:08 +02:00
MHSanaei ae6f13b533 fix: also hide QR code for ML-KEM-768 links (too long for QR generation) 2026-05-14 12:34:23 +02:00
MHSanaei 1cf2582e6d fix: hide QR code for mldsa65 links (too long for QR generation) 2026-05-14 12:30:48 +02:00
Abdalrahman eacb9f63b0 fix: protocol filter placeholder not showing on initial load (#4372) 2026-05-14 12:12:44 +02:00
MHSanaei e7035b56fe fix: sync advancedJson before tab switch in convertLink 2026-05-14 11:46:07 +02:00
dependabot[bot] 5f526e5201 build(deps): bump actions/setup-node from 5 to 6 (#4368)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 11:11:12 +02:00
MHSanaei bd8d33980f fix: ignore duplicate column errors during AutoMigrate on upgraded DBs
SQLite raises 'duplicate column name: <col>' when GORM tries to ADD a
column that already exists in an older schema (seen: allow_private_address,
node_id on the nodes table). This caused database initialisation to fail on
every restart after an upgrade.

The new isIgnorableDuplicateColumnErr helper skips the error only when:
  1. The error message matches 'duplicate column name: <col>'
  2. Migrator().HasColumn confirms the column is already present in the DB

Fresh databases and all other error types are unaffected.
2026-05-14 11:10:38 +02:00
MHSanaei 5dc02a9af3 v3.0.2 2026-05-14 10:27:33 +02:00
Abdalrahman 033c5993e0 feat: add API token to install output (#4322)
* feat: add API token to install output

Add -getApiToken flag to the setting subcommand so shell scripts
can retrieve the panel API token. Include the token in the
install.sh completion banner for automation/deployment use.

* fix(install): adapt -getApiToken CLI to multi-token service

settingService.GetApiToken was removed when API tokens moved to a
multi-row ApiTokenService. Switch the install-time CLI to list tokens
and create one named "install" if none exist, preserving the
`apiToken: <value>` output the install.sh grep depends on.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-14 10:24:23 +02:00
MHSanaei 2204c8231d Adjust QR panel sizing and collapse JSON subscription by default 2026-05-14 10:23:27 +02:00
Abdalrahman 01a7dc807b fix(sub): include xhttp mode in extra JSON for karing compatibility (#4365)
- Add mode to buildXhttpExtra() so clients reading xtra param
  (karing, etc.) receive the xhttp mode alongside other bidirectional
  SplitHTTP fields. Previously mode was only a flat URL param and was
  silently dropped when xtra was present.
- Add xhttp case to streamData() to strip acceptProxyProtocol and
  server-only fields (noSSEHeader, scMaxBufferedPosts,
  scStreamUpServerSecs, serverMaxHeaderBytes) from JSON sub configs.
- Sync frontend buildXhttpExtra() with the same mode addition.

Closes #4364
2026-05-14 10:02:45 +02:00
Farhad H. P. Shirvan 6bf4a2c4f0 fix(docker): update port mapping for 3xui service in docker-compose (#4362) 2026-05-14 10:00:09 +02:00
MHSanaei 21058eb63c fix(routing): make rule drag-and-drop work on mobile cards
The pointermove handler looked up the drop target via
el.closest('tr[data-row-key]'). That selector only matches the
desktop a-table rows; the mobile branch renders each rule as a
<div class="rule-card" data-row-key>, so on phones the lookup
always returned null, dropTargetIndex stayed pinned to the start
index, and the eventual drop was a no-op. Loosened the selector
to [data-row-key] so both DOM shapes resolve.
2026-05-14 02:04:05 +02:00
Black 194de8869e feat(panel): add 'Edit' button to tables and enhance layout (#4355)
- Move 'Edit' button from dropdown to the table since it's the most used action. Only for desktop.
- Increase column widths for action keys in Inbounds, Balancers, Outbounds and Routing tables.
- Slightly enhance layout for consistency.
2026-05-14 01:55:00 +02:00
MHSanaei 26accfd8f7 fix(qr): lock QR code modules to black-on-white across all themes
AntD's <a-qrcode> defaults the module color to the active theme's
text token. Under the dark and ultra-dark themes that text is a light
gray, so the QR rendered low-contrast on the white canvas background
and phones could not lock onto it. Pinned color="#000000" and
bg-color="#ffffff" on every <a-qrcode> usage (share links in
QrPanel, 2FA enrollment in TwoFactorModal, sub/json/clash codes on
SubPage) so the contrast stays high regardless of panel theme.
2026-05-14 01:45:00 +02:00
MHSanaei 2551a673c3 fix(inbounds): refresh client rows live over websocket
Two bugs combined to leave per-client traffic / remained / all-time
columns stuck at stale numbers while only the inbound-level row and
the online badge refreshed:

1. Backend (xray + node sync traffic jobs) only included the per-client
   array in the client_stats broadcast when activeEmails / touched
   was non-empty. Cycles with no client deltas — or any node sync that
   failed to fetch a snapshot — shipped only the inbound summary, so
   the frontend had nothing to merge for clients. Replaced both code
   paths with a single GetAllClientTraffics() snapshot per cycle; the
   broadcast now always carries the full client list.

2. Frontend mutated dbInbound.clientStats[i] in place. DBInbound is a
   plain class instance (not wrapped in reactive()), so Vue could not
   see the field-level changes and ClientRowTable's statsMap computed
   stayed cached forever. Added a statsVersion tick bumped on every
   merge and read inside statsMap so the computed re-evaluates and the
   template pulls fresh up/down/allTime/expiryTime each push.

Removed the now-dead emailSet helper from node_traffic_sync_job and
the activeEmails filter from xray_traffic_job.
2026-05-14 01:31:49 +02:00
MHSanaei ce4c42e09c feat(json): swap raw textareas for a CodeMirror 6 JsonEditor
A new JsonEditor.vue component wraps CodeMirror 6 + lang-json with
line numbers, JSON syntax highlighting, bracket matching, code
folding, search (Ctrl+F), undo/redo, lint (red squiggle and gutter
icon on invalid JSON), tab indent, and line wrapping. It is wired
into the four raw-JSON spots that previously used <a-textarea
class="json-editor">: the Xray Advanced Template tab, the Outbound
JSON tab, the Balancer Observatory pane, and the Inbound Advanced
tab (settings / streamSettings / sniffing).

Chrome colors are driven by EditorView.theme so they win the
specificity fight cleanly against CodeMirror's own injected styles.
A single buildDarkTheme() factory yields a Dark+ palette (#1e1e1e
background, #252526 active line, #2d2d30 panels) for the regular
dark mode and a near-black variant (#0a0a0a / #141414 / #1f1f1f
border) for ultra-dark — both pair with oneDarkHighlightStyle for
the syntax colors. Light mode stays on basicSetup's default.

CodeMirror lazy-loads as a ~17 kB gzipped chunk that only appears
on the Xray/Inbounds bundles.
2026-05-14 00:02:59 +02:00
MHSanaei 18614bd6ea feat(tabs): collapse settings and xray tab bars to evenly-spread icons
On phones the five Settings tabs and six Xray tabs overflowed the
viewport. Now the tab labels are stripped (v-if="!isMobile"), the
nav-list stretches to full width via display:flex + width:100%, and
each tab claims an equal share with flex:1 1 0 so the icons spread
across the row instead of bunching. Icons bumped to 18px with a
tooltip carrying the original label for discoverability.
2026-05-13 23:33:50 +02:00
MHSanaei e564c9283d feat(nodes): mobile card list, info modal, and tighter summary layout
NodeList now branches on isMobile: a vertical card list mirrors the
inbound mobile redesign — status dot + name + an Info icon that opens
an a-modal with the full per-node stats (address, status, CPU/mem,
xray version, uptime, latency, last heartbeat). The card head expands
to surface NodeHistoryPanel inline (parity with the desktop expandable
row), and the more-dropdown carries probe/edit/delete.

NodesPage also gets two layout fixes: an 8px vertical gutter between
the summary card and the node list on mobile (was 0), and a 2x2 grid
for the four summary statistics on phones via :xs="12" plus a 16px
inner vertical gutter, so Total/Online/Offline/Avg Latency no longer
crowd each other.
2026-05-13 23:14:56 +02:00
MHSanaei 933567d423 feat(inbounds): collapse mobile cards to id/email + info button
Mobile inbound cards now show only #id and remark; mobile client cards
show only the status badge and email. The full stat grid (protocol,
port, node, traffic, all-time, clients, expiry — and per-client
remained/online/expiry) moves behind a new info icon that opens an
a-modal, so the list stays scannable on small screens.
2026-05-13 23:03:19 +02:00
MHSanaei 771bc7c8ef feat(inbounds): align tunnel, tun, and hysteria UI with Xray docs
* tunnel: rename settings to Xray's current schema (address →
  rewriteAddress, port → rewritePort, network → allowedNetwork) in
  the model, form modal, info modal, and the bundled API inbound
  template; expose portMap so per-port forwarding can be configured
  from the panel.
* tun: add the full TUN protocol form and read-only info blocks
  (name, mtu, gateway, dns, userLevel, autoSystemRoutingTable,
  autoOutboundsInterface) — previously the protocol was selectable
  but the form rendered blank.
* hysteria: surface the stream-level version, obfs password, and
  udpIdleTimeout fields that the model already supported.

Refs https://xtls.github.io/config/inbounds/tunnel.html
Refs https://xtls.github.io/config/inbounds/tun.html
Refs https://xtls.github.io/config/transports/hysteria.html
2026-05-13 22:44:08 +02:00
MHSanaei 61ab602887 fix(iplog): parse xray access-log timestamps in local time
Xray writes access-log timestamps in the server's local timezone, but
time.Parse interpreted them as UTC, shifting the stored unix epoch by
the host offset. The panel rendered the epoch back to local time, so
CST users saw IP-log times 8 hours in the future. Parse the log
timestamp with time.ParseInLocation(time.Local) so it round-trips.

Fixes #4147
2026-05-13 21:50:16 +02:00
MHSanaei adc262a238 fix(warp): set license against Cloudflare API and surface errors inline
The license update was always failing because the Cloudflare response has
no `success` field — the check rejected every successful PUT. On real
errors (e.g. "Too many connected devices."), the toast leaked the raw URL
+ JSON body. Now the WARP API's error envelope is parsed into a clean
message and shown inline next to the Update button.
2026-05-13 21:13:16 +02:00
Vladislav Kasperov 67b098dfd3 Add possibility to remove client email from sub (#4297) 2026-05-13 19:04:17 +02:00
MHSanaei 5543466fcc fix(forms): validate JSON tabs before applying or saving
InboundFormModal: switching out of the Advanced tab now parses the three
JSON textareas and rebuilds the structured Inbound via Inbound.fromJson,
so the Basic tab reflects what was pasted. Invalid JSON keeps the user
on Advanced with a specific parse error.

XrayPage: Save now parses xraySetting upfront and snaps the user back to
the Advanced tab on invalid JSON instead of letting the backend reject a
generic blob.
2026-05-13 19:01:12 +02:00
MHSanaei b10a9f1de7 fix(inbounds): hide node UI when no enabled node exists
The Deploy-to selector, node column, node stat row, and node filter all
appeared whenever a node row existed in the DB. Local-only deployments
with no nodes (or only disabled nodes) saw a dropdown that only had
"Local Panel" and a filter that did nothing.

useNodeList now exposes hasActive (any node with enable === true).
Inbounds form and list gate node UI on hasActive instead of map size.
2026-05-13 17:42:40 +02:00
Amirmohammad Sadat Shokouhi 4399fe2a85 add log rotate to 3xui.log file to avoid disk space consumption (#4277)
* add log rotate to 3xui.log file to avoid disk space consumption
2026-05-13 17:03:56 +02:00
MHSanaei 6c6b40e063 fix(outbound): accept JSON-only configs and sync JSON to basic form on tab switch
Pasting a JSON config and clicking OK failed with "Something went wrong"
because validation read the empty form-side tag input instead of the
JSON's tag. Switching from the JSON tab to Basic also discarded any
JSON the user had pasted.

- onOk now validates and submits from the JSON tab using the parsed JSON
- Tab switch JSON→Basic deserializes the JSON back into the structured form
- Invalid JSON keeps the user on the JSON tab with a clear parse error
- Empty form-tag / duplicate-tag errors are now specific, not generic
2026-05-13 16:48:16 +02:00
MHSanaei b97ff40ad6 feat(api-tokens): manage multiple named tokens; add tab/section anchor URLs
Replace the single regenerable API token with a named-token list:
- New ApiToken model + service with constant-time auth matching
- Seeder migrates the legacy `apiToken` setting into a "default" row
- Security tab gets create/enable/delete UI; api-docs page links to it
- Dedicated "API Tokens" section in the in-panel docs

URL anchors now reflect the active tab/section on Settings, Xray, and
API Docs pages, so deep links like `/panel/settings#security` work.

Translations for the 8 new SecurityTab strings added across all locales.
2026-05-13 16:34:31 +02:00
MHSanaei 46b6f8c66c feat(routing): drag-reorder rules, split balancer column, mobile card layout
- Grip-handle drag-and-drop on the # cell to reorder rules, built on
  Pointer Events so the same code works for mouse, touch, and pen
  (HTML5 drag doesn't fire from touch on iOS Safari). 5px threshold
  keeps quick taps from triggering a reorder; up/down arrow menu
  items stay as a keyboard/a11y fallback. Drop indicator is a 2px
  blue line on the target edge; dragged row fades to 40%.
- Split the old combined target column into Outbounds and Balancer
  columns. Each row now has exactly one populated cell — green
  outbound tag or purple balancer tag.
- Mobile drops the a-table (520px+ of column widths overflowed every
  phone) for a stacked card layout: # + grip + actions on top, an
  "Inbound → Outbound/Balancer" flow row in the middle, and criteria
  chips (domain, IP, port, src IP/port, L4, protocol, user, VLESS)
  below for whichever fields are actually set. Multi-value chips
  collapse to "first +N" with full value on hover.
2026-05-13 15:30:25 +02:00
Abdalrahman 102df7a290 style(api-docs): redesign TOC, section icons, endpoint rows, and code blocks with ultra-dark support (#4332)
* style(api-docs): redesign TOC, section icons, endpoint rows, and code blocks with ultra-dark support

* style(api-docs): rename visibleSections to visibleEndpoints, drop dead toc-stuck CSS

- visibleSections counted endpoints, not sections — rename matches
  the displayed "X / Y endpoints" label.
- .toc-nav.toc-stuck was never toggled by any code path.

* docs(api): add missing POST /panel/api/inbounds/:id/resetTraffic entry

This route was added in #4334/#4338 but endpoints.js wasn't updated,
breaking TestAPIRoutesDocumented (91 routes in source, 90 documented).

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 15:05:23 +02:00
Abdalrahman f29c8a5e29 fix: single inbound traffic reset resets all inbounds (#4334) (#4338) 2026-05-13 14:49:54 +02:00
Abdalrahman ad81649c16 fix: strip main-panel TLS cert file paths when sending inbound to remote node (#4339)
When the main panel creates an inbound assigned to a remote node,
the wireInbound helper sends StreamSettings as-is, including
certificateFile/keyFile paths that only exist on the main panel's
filesystem. The remote node's Xray then fails to load them and crashes.

This adds sanitizeStreamSettingsForRemote() which strips file-based
cert paths before forwarding to a remote node. Inline certificate
content (certificate/key) is preserved unchanged.

Closes #4335
2026-05-13 14:47:09 +02:00
Abdalrahman b47f794ed0 fix: reality random target/sni buttons not working (#4337) (#4340) 2026-05-13 14:42:20 +02:00
MHSanaei 4e1b597914 feat(ui): use the host as the browser tab title prefix 2026-05-13 14:23:57 +02:00
MHSanaei bbefe91011 fix(auth): invalidate sessions when 2FA is enabled, fix dev 401 loop
Add UserService.BumpLoginEpoch and call it from updateSetting when
TwoFactorEnable flips false → true. Existing cookies (issued under
the looser no-2FA policy) get a 401 on their next request and are
forced through the login flow. Disabling 2FA is a relaxation and
does not bump the epoch — sessions stay valid.

Also fix the dev-mode 401 redirect: targeting `${basePath}login.html`
breaks when basePath isn't "/" (Vite has no file at e.g.
"/test/login.html"; the SPA fallback loops the 401). Navigate to
basePath instead — Vite's bypassMigratedRoute and Go's index
handler both serve login.html for that path.

Strip stale doc-comment from netsafe and IndexController.logout
in line with the project's no-inline-comments convention.
2026-05-13 14:08:16 +02:00
MHSanaei e40554a7d5 fix(inbound): require email when adding or updating a client
AddInboundClient and UpdateInboundClient previously accepted an
empty Email field for every protocol except shadowsocks (where
email doubles as the client ID). Empty emails break downstream
features that key off email — IP-limit logging, traffic stats,
client-online tracking, subscription remarks.

Reject empty/whitespace-only emails at the service layer so the
API surface (POST /panel/api/inbounds/addClient and
/updateClient/:id) returns a clear error instead of persisting
an unidentifiable client.

Also drop the stale `len(Email) > 0` guard in UpdateInboundClient
that became dead code once empty emails are rejected.
2026-05-13 13:45:31 +02:00
MHSanaei 3569b1be73 ci(codeql): run on push to main 2026-05-13 13:39:32 +02:00
MHSanaei 38da210ded fix(security): SSRF-guard node and remote HTTP clients
The Node.Probe and Remote.do paths built outbound URLs by string-
formatting admin-controlled fields (Scheme/Address/Port/BasePath)
straight into requests, then dialed the result with the default
transport. CodeQL flagged this as go/request-forgery — an admin
(or anyone who compromises the admin account) could point a node
at internal infrastructure (cloud metadata, RFC1918 ranges, etc.)
and the panel would dutifully fetch it.

Add util/netsafe with a shared TOCTOU-safe DialContext that
resolves the host, rejects private/internal IPs unless the
per-request context whitelists them (per-node AllowPrivateAddress
flag, plumbed through context.Value), and dials the resolved IP
directly so the IP that passed the check is the IP we connect to.
This closes the DNS-rebinding window where a hostname could
resolve to a public IP at check time and a private one at dial.

Also tighten address validation (NormalizeHost rejects anything
that isn't a bare hostname or IP literal — no embedded paths,
userinfo, schemes) and switch URL construction from fmt.Sprintf to
url.URL{} + net.JoinHostPort so admin-supplied values can't smuggle
URL components.

custom_geo.go's isBlockedIP now delegates to netsafe so there's
one source of truth.
2026-05-13 13:33:53 +02:00
MHSanaei 9fc47b3d41 ci: gate workflows on relevant source paths
- ci.yml: only run on Go/frontend source and lockfiles.
- codeql.yml: scope push/PR triggers to Go and JS/TS sources;
  weekly cron still does a full scan.
- release.yml: add matching paths allowlist to pull_request so
  doc/workflow-only PRs don't kick off the multi-arch build.

Skips workflow runs on changes to docs, translations, GitHub
configs, and unrelated scripts.
2026-05-13 13:21:26 +02:00
MHSanaei 210c25cf13 Bump Go module dependency versions
Routine update of Go module dependencies and tidy: bump indirect deps (github.com/quic-go/quic-go v0.59.0→v0.59.1, github.com/sagernet/sing v0.8.9→v0.8.10, github.com/tklauser/go-sysconf v0.3.16→v0.4.0, github.com/tklauser/numcpus v0.11.0→v0.12.0), and update several golang.org/x modules (arch, exp, mod, net, tools) and google.golang.org/genproto. Removed duplicate require entries (x/crypto, x/sys, x/text) and updated go.sum to match the new versions.
2026-05-13 13:04:44 +02:00
dependabot[bot] 5dd7e44594 build(deps): bump golang.org/x/text from 0.36.0 to 0.37.0 (#4345)
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.36.0 to 0.37.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.36.0...v0.37.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 13:00:02 +02:00
dependabot[bot] 4e4a8e9ff7 build(deps): bump golang.org/x/crypto from 0.50.0 to 0.51.0 (#4344)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.50.0 to 0.51.0.
- [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.51.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 12:58:42 +02:00
dependabot[bot] 23970e72a7 build(deps): bump golang.org/x/sys from 0.43.0 to 0.44.0 (#4343)
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/sys/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 12:57:07 +02:00
dependabot[bot] 8bdb093d6e build(deps): bump actions/setup-node from 5 to 6 (#4342)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 12:56:51 +02:00
dependabot[bot] 3b0bcb910e build(deps): bump actions/checkout from 5 to 6 (#4341)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 12:56:08 +02:00
Farhad H. P. Shirvan 428f1333ac Security hardening: sessions, SSRF, CSP nonce, CSRF logout, trusted proxies (#4275)
* refactor(session): store user ID in session instead of full struct

Replaces storing the full User object in the session cookie with just
the user ID. GetLoginUser now re-fetches the user from the database on
every request so credential/permission changes take effect immediately
without requiring a re-login. Includes a backward-compatible migration
path for existing sessions that still carry the old struct payload.

* feat(auth): block panel with default admin/admin credentials and guide credential change

checkLogin middleware now detects default admin/admin credentials and
redirects every panel route to /panel/settings until they are changed.
The settings page auto-opens the Authentication tab, shows a
non-dismissible error banner, and lists 'Default credentials' first in
the security checklist. Login response includes mustChangeCredentials
so the login page can redirect directly. Logout is now POST-only.
Password must be at least 10 characters and cannot be admin/admin.

* feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs

Introduces AllSettingView which strips tgBotToken, twoFactorToken,
ldapPassword, apiToken and warp/nord secrets before sending them to
the browser, replacing them with boolean hasFoo presence flags. A new
/panel/setting/secret endpoint allows updating individual secrets by
key. Secrets that arrive blank on a save are preserved from the DB
rather than overwritten. Adds TrustedProxyCIDRs as a configurable
setting (defaults to localhost CIDRs). URL fields are validated before
save.

* fix(security): SSRF prevention, trusted-proxy header gating, CSP nonce, HTTP timeouts

Adds SanitizeHTTPURL / SanitizePublicHTTPURL to reject private-range
and loopback targets before any outbound HTTP request (node probe,
xray download, outbound test, external traffic inform, tgbot API
server, panel updater). Forwarded headers (X-Real-IP, X-Forwarded-For,
X-Forwarded-Host) are now only trusted when the direct connection
arrives from a CIDR in TrustedProxyCIDRs. CSP policy is tightened with
a per-request nonce. HTTP server gains read/write/idle timeouts. Panel
updater downloads the script to a temp file instead of piping curl into
shell. Xray archive download adds a size cap and response-code check.
backuptotgbot is changed from GET to POST.

* feat(nodes): add allow-private-address toggle per node

Adds AllowPrivateAddress to the Node model (DB default false). When
enabled it bypasses the SSRF private-range check for that node's probe
URL, allowing nodes hosted on RFC-1918 or loopback addresses (e.g.
a private VPN or LAN setup).

* chore: frontend UX improvements, CI pipeline, and dev tooling

- AppSidebar: logout via POST /logout instead of navigating to GET
- InboundList: persist filter state (search, protocol, node) to
  localStorage across page reloads; add protocol and node filter dropdowns
- IndexPage: add health status strip (Xray, CPU, Memory, Update) with
  quick-action buttons
- dependabot: weekly go mod and npm update schedule
- ci.yml: add GitHub Actions workflow for build and vet
- .nvmrc: pin Node 22 for local development
- frontend: bump package.json and package-lock.json
- SubPage, DnsPresetsModal, api-docs: minor fixes

* fix(ci): stub web/dist before go list to satisfy go:embed at compile time

* chore(ui): remove health-strip bar from dashboard top

* Revert "feat(auth): block panel with default admin/admin credentials and guide credential change"

This reverts commit 56ce6073ce.

* fix(auth): make logout POST+CSRF and propagate session loss to other tabs

- Switch /logout from GET to POST with CSRFMiddleware so it matches the
  SPA's existing HttpUtil.post('/logout') call (previously 404'd silently)
  and blocks GET-based logout via image tags or link prefetchers. Handler
  now returns JSON; the SPA already navigates client-side.
- Return 401 (instead of 404) from /panel/api/* when the caller is a
  browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor
  redirects to the login page on logout-in-another-tab, cookie expiry,
  and server restart. Anonymous callers still get 404 to keep endpoints
  hidden from casual scanners.
- One-shot the 401 redirect in axios-init.js and hang the rejected
  promise so queued polls don't stack reloads or surface error toasts
  while the browser is navigating away.
- Add the CSP nonce to the runtime-injected <script> in dist.go so the
  panel loads under the existing script-src 'nonce-...' policy.
- Update api-docs endpoints.js: GET /logout doc entry was missing.

* fix(settings): POST /logout after credential change

* fix(auth): invalidate other sessions when credentials change

When the admin changes username/password from one machine, sessions
on every other machine kept working until they manually logged out
because session storage is a signed client-side cookie — there is
no server-side session list to revoke.

Add a per-user LoginEpoch counter stamped into the session at login
and re-verified on every authenticated request. UpdateUser and
UpdateFirstUser bump the epoch (UpdateUser via gorm.Expr so a single
update statement is atomic), so any cookie issued before the change
no longer matches the user's current epoch and GetLoginUser returns
nil — the SPA's 401 interceptor then redirects to the login page.

Backward compatible: the column defaults to 0 and missing cookie
values are treated as 0, so sessions issued before this change
remain valid until the first credential update.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 12:52:52 +02:00
MHSanaei 406cb6dbc0 fix(api-docs): resolve no-useless-escape lint errors
- endpoints.js: replace `\"` with `\\"` in xray response example so the
  rendered docs actually show escaped JSON-in-JSON (the original
  single-quoted `\"` collapsed to a bare `"` and produced malformed output).
- CodeBlock.vue: drop the unnecessary `\[` inside the regex character
  class `[{}\[\]]`; `[` does not need escaping inside `[...]`.
2026-05-13 11:31:34 +02:00
Aleksandr 5fb36d34c9 fix(fail2ban): escape percent signs in 3x-ipl datepattern (#4328)
* Update DockerEntrypoint.sh

fix(fail2ban): escape percent signs in Docker datepattern

* Update x-ui.sh

fix(fail2ban): escape percent signs in x-ui datepattern
2026-05-13 01:49:09 +02:00
Abdalrahman 4884a2972a fix(graphs): increase y-axis paddingLeft from 32 to 56 to prevent clipped labels (#4309) 2026-05-13 01:47:54 +02:00
Abdalrahman 6e12329d9d feat(api-docs): enhance in-panel API documentation (#4312)
* feat(api-docs): enhance API documentation with missing endpoints, search, collapse, and route sync test

- Add 29 undocumented routes across 4 new sections (Settings, Xray Settings,
  Subscription Server, WebSocket) plus 4 missing Server API endpoints
- Fix inaccuracies: history metric keys, node metric keys, VLESS enc description
- Add response schemas to 15+ key endpoints
- Add search bar and expand/collapse all controls to the docs page
- Add collapsible endpoint sections with endpoint count
- Add Go test (TestAPIRoutesDocumented) to verify all Go routes are documented

* feat(api-docs): add JSON syntax highlighting and top-right copy button to code blocks

* fix(api-docs): use distinct colors for JSON syntax highlighting (green strings, amber numbers)

* feat(api-docs): add request body examples, error responses, WebSocket message types, and subscription response headers

* fix(api-docs): use ClipboardManager.copyText instead of copy to fix API token copy button
2026-05-13 01:47:09 +02:00
Abdalrahman 9f7e8178d4 fix: delete button missing after searching for a user (#4315)
When searching for a user, the projected DBInbound only contains the
matching clients, so isRemovable evaluated to alse (since a single
match made clients.value.length === 1), hiding the Delete button.

Pass the original total client count from the parent's clientCount
prop and use it in the isRemovable check instead of the projected
clients array length.
2026-05-13 01:27:10 +02:00
Abdalrahman 60e6b12f4c fix(hysteria2): restore missing masquerade config in inbound form (#4316)
* fix(hysteria2): restore missing masquerade config in inbound form

Fixes #4303

The Hysteria2 Masquerade option was missing from the Stream settings
tab after the v3.0.0 form rewrite. Added the UI form and ensured the
masquerade block is passed through in subscription JSON generation.
2026-05-13 01:25:00 +02:00
Abdalrahman 0dbadf82c0 fix: auto-renew must re-enable client in inbound settings JSON (#4317)
Since v2.9.4, disableInvalidClients sets c['enable']=false in inbound settings JSON when a client hits its limit. autoRenewClients only updated client_traffics.enable - never flipped the JSON field back. The Xray config generator checks both, so client stayed excluded after renewal.
2026-05-13 01:15:52 +02:00
Abdalrahman 48e90bba51 fix: show UDP tag for Hysteria and fix client count spacing (#4318) 2026-05-13 01:12:25 +02:00
Abdalrahman 6de9b24229 fix: preserve space between date and time in log modal (#4326)
Vue 3's whitespace: condense strips bare whitespace text nodes and
trailing whitespace inside elements, causing the &lt;template&gt; trick
to fail. Use mustache interpolations (which compile to _createTextVNode)
for all spacing between fields so they survive compilation.
2026-05-13 01:02:48 +02:00
MHSanaei 07bc74a521 feat(nodes): blur address column with eye-toggle, mirroring IndexPage IP card 2026-05-12 12:38:38 +02:00
MHSanaei f570b991e7 fix(api-docs): copy API token button 2026-05-12 12:34:22 +02:00
MHSanaei 80031e67cc feat(inbounds): restore copy-clients-between-inbounds modal
The menu item, backend endpoint (POST /panel/api/inbounds/:id/copyClients),
and i18n keys were already in place after the Vue3 migration, but the modal
itself was never ported — clicking the menu just toasted "coming soon".

Adds CopyClientsModal.vue: source inbound dropdown (multi-user inbounds
except the target), per-client checkbox selection via a-table row-selection,
optional Flow override when the target supports TLS flow, and result toasts
for added/skipped/errors.
2026-05-12 12:30:07 +02:00
Farhad H. P. Shirvan fdaa65ad7e Feat: clarify VLESS encryption auth selection (#4271)
* feat(traffic_writer): enhance traffic writer with concurrency safety and state management

* Revert "feat(traffic_writer): enhance traffic writer with concurrency safety and state management"

This reverts commit e6760ae396.

* feat(vless): clarify VLESS encryption auth selection and enhance parsing logic
2026-05-12 11:39:28 +02:00
Farhad H. P. Shirvan d86e87ed30 Fix: traffic writer restart freeze (#4265)
* feat(traffic_writer): enhance traffic writer with concurrency safety and state management

* Revert "feat(traffic_writer): enhance traffic writer with concurrency safety and state management"

This reverts commit e6760ae396.

* feat(traffic_writer): enhance traffic writer with concurrency safety and state management

* feat(web): implement panel-only start/stop methods for in-process restarts
2026-05-12 11:36:05 +02:00
Abdalrahman 89a8f549f2 feat: sortable inbounds table columns (#4300) 2026-05-12 11:29:32 +02:00
MHSanaei 355bb4c9c0 feat(panel): xray metrics dashboard with observatory probe history
Polls xray's /debug/vars on the 2s status tick, stores memstats and per-outbound observatory delay in the metric history ring buffer, and exposes them through a new XrayMetricsModal opened from the Charts card. Restructures the dashboard to consolidate uptime, usage, version, and Telegram link into stat-style or action-style cards consistent with the existing AntD aesthetic.
2026-05-12 02:17:45 +02:00
MHSanaei 9feeccffc0 fix(node): normalize base path during probe so missing trailing slash doesn't break status checks 2026-05-12 00:27:49 +02:00
MHSanaei cb962175c2 update translation 2026-05-11 20:47:49 +02:00
MHSanaei 8f3202f431 fix(traffic-writer): replace sync.Once with Start/Stop cycle so SIGHUP restart works
After a SIGHUP-driven panel restart (which is exactly what the frontend
triggers after a successful DB import via /panel/setting/restartPanel),
the previous implementation deadlocked:

1. server.Stop() called StopTrafficWriter — cancels the context and waits
   for the consumer goroutine to exit. The goroutine dies.
2. server.Start() called StartTrafficWriter, but sync.Once had already
   fired, so it was a no-op. twQueue still pointed to the old channel
   with no consumer.
3. startTask() → RestartXray(true) → GetXrayConfig() →
   InboundService.AddTraffic(nil, nil) → submitTrafficWrite. The send
   to twQueue succeeded (buffer space) but <-req.done blocked forever
   because no goroutine was draining the channel.
4. RestartXray held the global xray lock for the entire hang, so every
   subsequent restart attempt from the panel UI also blocked on
   lock.Lock(). User-visible symptom: xray stopped silently after DB
   import and no panel action could revive it.

Replace sync.Once with a mutex-guarded Start that spawns a fresh
goroutine on each cycle, and a Stop that resets the package state so
the next Start works. runTrafficWriter now takes its channels as
parameters instead of reading package vars, so the old goroutine can't
interfere with a new one if their lifetimes briefly overlap.
2026-05-11 16:01:04 +02:00
MHSanaei 0cb6568fd5 v3.0.1 2026-05-11 15:05:23 +02:00
MHSanaei 6a90f98412 feat(inbounds): add sub/client link endpoints; hide panel version on login
- New GET /panel/api/inbounds/getSubLinks/:subId and /getClientLinks/:id/:email
  return the same protocol URLs the panel UI's Copy button emits, honouring
  X-Forwarded-Host / X-Forwarded-Proto. Documented in the API docs page.
- Refactor: sub package no longer imports web. The embedded dist FS is
  injected via sub.SetDistFS, and the link generator is registered with the
  service layer via service.RegisterSubLinkProvider, avoiding the circular
  import the new endpoints would otherwise introduce.
- Security: stop emitting window.X_UI_CUR_VER on login.html and drop the
  visible version chip from the login page, so the panel version is no
  longer pre-auth info disclosure. Authenticated pages still receive it.
- Bump config/version.
2026-05-11 15:03:47 +02:00
Farhad H. P. Shirvan 9318c2105f fix(xray): implement graceful shutdown for xray process and add tests (#4259) 2026-05-11 14:11:40 +02:00
MHSanaei e642f7324e feat(panel): in-panel API documentation page
New /panel/api-docs route with a one-page reference covering every
/panel/api/* endpoint (Auth, Inbounds, Server, Nodes, Custom Geo,
Backup) plus a Bearer-token primer that reads the current token and
exposes Show/Copy/Regenerate inline. Sidebar gets an API Docs entry
right after Xray; the menu label is shared via menu.apiDocs across all
13 locales.
2026-05-11 13:57:42 +02:00
MHSanaei 7214ffafc5 fix(inbounds): scope port check to node and preserve caller tag
Different nodes are different machines, so same port + transport across
NodeIDs shouldn't conflict. resolveInboundTag now keeps a caller-supplied
unique tag verbatim so central and node panels stay in agreement instead
of regenerating into a UNIQUE constraint failure on sync.
2026-05-11 12:51:45 +02:00
MHSanaei 88061bac10 fix(theme): default to dark, polish theme cycle visibility and hover
New installs land on plain dark instead of ultra-dark. The cycle button
icon now has an explicit colour so it stays visible inside the mobile
drawer (the previous color:inherit didn't cascade through the teleported
node), and hover/focus matches the menu's blue across sidebar, login,
and sub pages.
2026-05-11 12:51:37 +02:00
MHSanaei b5479f3f30 feat(sidebar): pin Logout above trigger, inline 3-state theme cycle
The desktop sider stretched to match the page height, so below lg
(992px) where dashboard cards stack into one column the collapse
trigger plus Logout slid off-screen. Pin the sider with
`position: sticky; height: 100vh; align-self: flex-start` so the chrome
stays viewport-tall. Split the menu into `.sider-nav` (flex: 1,
scrollable) and `.sider-utility` so Logout sits directly above the
48px trigger reserved by padding-bottom.

Replace the `<ThemeSwitch>` a-sub-menu with a single inline icon
button next to the '3X-UI' brand (sun / moon / moon+star SVG). One
click cycles Light -> Dark -> Ultra Dark -> Light. ThemeSwitch.vue
removed since it is now inlined.

Override AD-Vue dark Menu selected + hover/active state on the
sider-nav, sider-utility, and drawer menus to use the same light-blue
tint AD-Vue's light theme uses (rgba(64,150,255,0.2) / #4096ff). The
default dark variant was too subtle against #252526, so the current
page and Logout-on-hover barely distinguished themselves.
2026-05-11 12:05:45 +02:00
MHSanaei d8aedcdde4 fix(inbounds): bulk-delete keeps last client to satisfy backend constraint
DelClient rejects the removal that would leave an inbound with zero
clients (the constraint exists because Xray protocols need at least
one client to keep the inbound functional). The bulk-delete flow
fired one DelClient call per picked client in a loop, so picking
every client meant the final iteration always errored out with
"no client remained in Inbound" and surfaced as a red toast even
though N-1 deletions had already gone through.

Now confirmBulkDelete detects the "all selected" case up front,
drops the last client from the request, and surfaces the partial
operation in the confirm dialog ("N-1 / N — last selected will
remain. Delete the inbound to remove all."). The pre-existing
single-row delete path and partial-selection bulk delete paths are
untouched. If the only client in the inbound is selected, a
Modal.warning explains the constraint instead of asking for confirm.
2026-05-11 10:22:52 +02:00
MHSanaei 5f3e9ed0ea feat(xray/nord): searchable server list + colored load tag, surface API errors
Frontend (NordModal.vue):
- Server selector gets show-search with the option label set to
  `${cityName} ${name} ${hostname}` so admins can find a specific
  server inside a 100+ entry country list by typing.
- Each option renders the load as a colored a-tag (green <30%,
  orange 30-70%, red >70%) instead of plain text — quicker visual
  scan when sorting through servers in the dropdown.

Backend (nord.go):
- GetCountries / GetServers now check resp.StatusCode and return
  "NordVPN API error: <status>" on non-200, matching the pattern
  GetCredentials already used. Previously a 4xx/5xx body was
  returned as a "success" string and the frontend silently failed
  to parse it, surfacing only as an empty "No servers found".
- GetCredentials drops its own ad-hoc 10s http.Client and reuses
  the shared nordHTTPClient (15s) — one client, one timeout.
2026-05-11 10:06:01 +02:00
MHSanaei 3e8a0eb93e fix(inbounds): paginate expanded client list, restore ID column, hide empty Remark
- ClientRowTable now applies the General-Settings pageSize to its
  expanded client list. The 3.0 rewrite dropped pagination, so users
  with thousands of clients per inbound hit a 30-60s browser hang on
  expand (#4233).
- ID column was marked responsive: ['xs'] so it was hidden on desktop;
  removed the restriction so it shows as the first column everywhere.
- Remark column is now omitted entirely when no inbound has a non-empty
  remark, matching the existing Node-column pattern.
2026-05-11 09:05:47 +02:00
MHSanaei 4c2915586c fix(alpine): restart_xray uses rc-service; OpenRC reload reads pidfile contents
`14. Restart Xray` failed on Alpine with `systemctl: command not found` —
restart_xray was the only service action missing an Alpine branch. While
fixing it, the OpenRC reload action was passing the pidfile path to `kill`
instead of the PID inside it, so `rc-service x-ui reload` would have
failed too.
2026-05-11 09:05:36 +02:00
Harry NG 9f06bffbea chore: fix remarks shadowrocket subscription (#4247) 2026-05-11 08:24:22 +02:00
Amirmohammad Sadat Shokouhi e20d73ba7e add loopback and dns servers tag to inbound lists in RuleFormModal (#4244)
* add loopback and dns servers tag to inbound lists in RuleFormModal

* fix: remove clientIp from dns section when its empty
2026-05-11 08:23:30 +02:00
MHSanaei 8834e5fbbe feat(xray/outbounds): TCP probe mode + Test All + timing breakdown
- service.TestOutbound now dispatches on `mode`:
  - "tcp": parallel net.DialTimeout to every server/peer endpoint
    (vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up,
    no semaphore — safe to run concurrently across outbounds.
  - "http" (default): existing temp-xray + SOCKS path, now with an
    httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB)
    alongside the total delay and status code.
- testSemaphore renamed to httpTestSemaphore — only HTTP probes
  serialise, TCP runs free.
- TestOutboundResult carries the per-mode extras: timing fields for
  HTTP, per-endpoint dial list for TCP, plus a `mode` echo.
- Controller reads `mode` from the form and passes it through.
- useXraySetting: testOutbound accepts mode (default "tcp"); new
  testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP,
  1 for HTTP) and skips blackhole / loopback / blocked outbounds —
  also skips freedom / dns under TCP since they have no endpoint.
- OutboundsTab: TCP/HTTP radio toggle and a Test All button land in
  the toolbar; the per-row  now uses the selected mode. Results
  surface in a popover with the full timing breakdown plus the
  endpoint list for TCP probes. Latency header replaces the duplicate
  "check" column title.

Practical effect: testing ten outbounds in TCP mode drops from ~50–100s
(serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the
authoritative probe and now shows where the latency actually lives.
2026-05-11 04:17:23 +02:00
MHSanaei 6d732d8d32 feat(inbounds): bulk-select clients + UX polish
- ClientBulkModal: add `comment` and VLESS `reverseTag` fields so the
  bulk-add modal can set them on every generated client (matching the
  single-client form)
- ClientRowTable: add multi-select checkboxes (desktop + mobile) with a
  tri-state select-all and a sticky bulk-action bar; emits a new
  `delete-clients` event so the parent can wipe the picked clients in
  one go. Hidden entirely when the inbound has only one client (the
  last one must stay)
- ClientRowTable: new "Remained" column shows live remaining quota
  per client (∞ for unlimited, red when depleted)
- InboundInfoModal: Remained cell now shows the ∞ tag when the client
  has no totalGB limit, matching how Total Usage already renders it
- InboundsPage: add Online tag (+ per-bucket popovers listing client
  emails) to the summary card so it mirrors the per-inbound row, and
  wire an `onDeleteClients` handler that loops the existing single-
  delete endpoint then refreshes once
- InboundList: forward the `delete-clients` event; hide empty remarks
  on both the desktop table (custom #bodyCell) and the mobile card
- useInbounds: aggregate an `online` email list across all inbounds
  so the summary popover has data to render
2026-05-11 03:50:28 +02:00
MHSanaei e4900f1bd4 feat(install): add skip-SSL option for reverse-proxy / SSH-tunnel setups
Adds a 4th choice to the install-time SSL prompt for users who terminate
TLS elsewhere (nginx, Caddy, Traefik) or only reach the panel through an
SSH tunnel — closes #3802.

- Option 4 prints a clear warning, then optionally binds the panel to
  127.0.0.1 via `x-ui setting -listenIP` so it's unreachable from the
  public internet
- When the user binds to 127.0.0.1, print the same SSH port-forwarding
  command set that x-ui.sh's SSH_port_forwarding() already shows, so
  remote access is one ssh -L away
- Track SSL_SCHEME so the final "Access URL:" line shows http:// when
  SSL is skipped, instead of misleadingly advertising https://
- Soften the section header from "(MANDATORY)" to "(RECOMMENDED)" and
  print "SSL Certificate: Skipped" when option 4 is chosen
- Rework the SSL menu copy to a parallel "verb — what (constraint)"
  shape with a single Tip line focused on option 4's risks
2026-05-11 02:46:47 +02:00
MHSanaei 04828246fc feat(frontend): swap QRious for ant-design-vue's a-qrcode
- Migrate SubPage, QrPanel and TwoFactorModal from a QRious canvas to
  <a-qrcode type="svg">, which renders the QR matrix as crispEdges
  SVG rectangles — pixel-perfect at any display size or DPR, no more
  white scan-line artifacts from non-integer canvas scaling
- Drop the now-unused qrious dependency and its manualChunks entry
- Default the panel to ultra-dark on first load (existing user
  preferences in localStorage are preserved)
- Let the sub controller read subpage.html from web/dist/ first and
  fall back to the embedded copy, so Vite rebuilds in dev no longer
  require a Go recompile to refresh the asset hashes
2026-05-11 02:07:47 +02:00
MHSanaei c1efc48694 feat(frontend): refresh dark theme + redesign login page
- Swap navy dark palette for VS Code Dark+ neutrals (#1e1e1e/#252526/
  #2d2d30) across theme tokens, page backgrounds and DateTimePicker
- Add brand header to the mobile drawer and desktop sider, and recolor
  the drawer body so it reads as one panel with the menu
- Redesign login page with a centered card, cycling Hello/Welcome
  headline and per-theme animated gradient-blob backgrounds
2026-05-11 01:10:05 +02:00
MHSanaei f1760b0a28 feat(xray/balancer): restore observatory editor + auto-sync selectors
The Vue3 migration dropped the Observatory / Burst Observatory section
that used to sit under the balancer table. Without it, leastPing /
leastLoad strategies had nowhere to populate Xray's required
subjectSelector, so balancers that depended on probe data silently
ran with an empty observer config.

- Auto-seed and sync `observatory` for leastPing balancers and
  `burstObservatory` for leastLoad balancers (subjectSelector
  recomputed from every matching balancer's selector list). Drops
  the observatory when no matching strategy remains.
- Defaults (probeURL, interval, connectivity, sampling) match the
  values the legacy panel shipped, themselves taken from the Xray
  docs at xtls.github.io/config/{observatory,burstobservatory}.html.
- Surface both observatories under the table as a radio-switched
  JSON textarea so admins can tune probe settings inline without
  dropping into the full xray template tab.
2026-05-11 00:11:09 +02:00
MHSanaei 745e394c74 refactor(panel): rename injected globals + collapse QR modal entries
Rename the SPA globals injected by Go to drop the ad-hoc dunder shape
and free up the bare `webBasePath` name (still the DB setting key)
from colliding with the JS global it used to share:
  window.__X_UI_BASE_PATH__ -> window.X_UI_BASE_PATH
  window.__X_UI_CUR_VER__   -> window.X_UI_CUR_VER

Also rework the QR-Code modal to fold every QR (subscription + JSON
sub URL, share links, WireGuard config/peer links) into a single
a-collapse with one panel per QR. Subscription panels are listed
first and open by default; everything else stays collapsed so a
multi-link inbound no longer scrolls forever.
2026-05-10 23:40:39 +02:00
MHSanaei 737300b14b fix(outbound): default VLESS encryption to "none"
A blank encryption field caused Xray to reject the outbound config with
'VLESS users: please add/set "encryption":"none"'. Default the
constructor parameter, coerce empty values, and final-guard toJson so
every code path emits a valid encryption value.
2026-05-10 23:06:28 +02:00
GRCR13 30469fcd10 fix: backup path with webbasepath (#4223)
* Update BackupModal.vue

add base path to importDB API call

* fix

add base path to importDB API call
2026-05-10 22:48:35 +02:00
MHSanaei 887fca86ec fix(fail2ban): escape % in 3x-ipl action date format (#4218)
Fail2ban parses % as variable interpolation in action.d configs, so the
unescaped %Y/%m/%d %H:%M:%S in the date command crashed fail2ban on
startup. Double the %s in the heredoc so the rendered action file
contains %% and fail2ban collapses it back to a literal % when invoking
the shell command.
2026-05-10 19:26:21 +02:00
381 changed files with 47199 additions and 25066 deletions
+8
View File
@@ -9,3 +9,11 @@ updates:
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
+89
View File
@@ -0,0 +1,89 @@
name: CI
on:
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.html"
- "**.css"
- "frontend/package.json"
- "frontend/package-lock.json"
- ".nvmrc"
push:
branches:
- main
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.html"
- "**.css"
- "frontend/package.json"
- "frontend/package-lock.json"
- ".nvmrc"
permissions:
contents: read
jobs:
go-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub web/dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Test
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub web/dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Build
run: npm run build
working-directory: frontend
- name: Audit
run: npm audit --audit-level=high
working-directory: frontend
+20 -3
View File
@@ -2,9 +2,29 @@ name: "CodeQL Advanced"
on:
push:
branches:
- main
tags-ignore:
- "v*"
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "frontend/package-lock.json"
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "frontend/package-lock.json"
schedule:
- cron: "18 2 * * 2"
@@ -35,9 +55,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
# The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
# web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
# "pattern all:dist: no matching files found" unless vite emits it first.
- name: Setup Node.js
if: matrix.language == 'go'
uses: actions/setup-node@v6
+13 -2
View File
@@ -19,6 +19,17 @@ on:
- "x-ui.service.arch"
- "x-ui.service.rhel"
pull_request:
paths:
- "**.js"
- "**.css"
- "**.html"
- "**.sh"
- "**.go"
- "go.mod"
- "go.sum"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
jobs:
build:
@@ -105,7 +116,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -239,7 +250,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"
+3
View File
@@ -16,6 +16,9 @@ tmp/
backup/
bin/
dist/
!web/dist/
web/dist/*
!web/dist/.gitkeep
release/
node_modules/
+1
View File
@@ -0,0 +1 @@
22
+5 -19
View File
@@ -10,26 +10,12 @@
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true"
"XUI_DEBUG": "true",
"XUI_DB_FOLDER": "x-ui",
"XUI_LOG_FOLDER": "x-ui",
"XUI_BIN_FOLDER": "x-ui"
},
"console": "integratedTerminal"
},
{
"name": "Run 3x-ui (Debug, custom env)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
// Set to true to serve assets/templates directly from disk for development
"XUI_DEBUG": "true",
// Uncomment to override DB folder location (by default uses working dir on Windows when debug)
// "XUI_DB_FOLDER": "${workspaceFolder}",
// Example: override log level (debug|info|notice|warn|error)
// "XUI_LOG_LEVEL": "debug"
},
"console": "integratedTerminal"
}
]
}
}
+14
View File
@@ -70,6 +70,20 @@
"problemMatcher": [
"$go"
]
},
{
"label": "go: fmt",
"type": "shell",
"command": "gofmt",
"args": [
"-l",
"-w",
"."
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
}
]
}
+225 -4
View File
@@ -1,5 +1,226 @@
## Local Development Setup
# Contributing
- Create a directory named `x-ui` in the project root
- Rename `.env.example` to `.env `
- Run `main.go`
Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running locally and explains the conventions the project follows so changes land cleanly.
## Prerequisites
- **Go 1.26+** (the version pinned in `go.mod`)
- **Node.js 22+** and npm 10+ (for the React frontend)
- **Git**
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
### Windows: MinGW-w64
`go build` on Windows fails with `cgo: C compiler "gcc" not found` until a GCC toolchain is installed. Two options — pick whichever fits.
**Option A — standalone zip (fastest, no package manager)**
1. Download the latest build from <https://github.com/niXman/mingw-builds-binaries/releases>. For most setups, pick a release named:
```
x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
```
(64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches modern Windows defaults.)
2. Extract it somewhere stable, e.g. `C:\mingw64\`.
3. Add `C:\mingw64\bin` to the **Windows** `PATH` (System Properties → Environment Variables → Path → New).
4. Open a fresh terminal and confirm:
```powershell
gcc --version
```
**Option B — MSYS2 (when a Unix shell is also useful)**
1. Install MSYS2 from <https://www.msys2.org/>.
2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
```bash
pacman -Syu
```
3. Install the UCRT64 toolchain:
```bash
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
```
4. Add `C:\msys64\ucrt64\bin` to the Windows `PATH`.
5. Verify with `gcc --version` in a fresh terminal.
After either path, `go build ./...` and `go run .` work normally.
> **Why MinGW-w64 over MSVC:** `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain does not require a Visual Studio install. If Visual Studio Build Tools are already present that works too — just make sure `CC=cl` is **not** set in the environment.
Cross-building the Linux SQLite target from Windows (or vice versa) requires a separate cross-compiler and is out of scope here; build natively on the target OS.
## First-time setup
```bash
git clone https://github.com/MHSanaei/3x-ui.git
cd 3x-ui
cp .env.example .env
mkdir x-ui
go mod download
cd frontend
npm install
npm run build
cd ..
```
`.env.example` ships with defaults that keep the database, logs, and xray binary inside the local `x-ui/` folder so nothing escapes the project directory:
```
XUI_DEBUG=true
XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
```
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
## Running
```bash
go run .
```
Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. Credentials must be changed on first login.
### Inside VS Code
The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent):
```jsonc
{
"version": "0.2.0",
"configurations": [
{
"name": "Run 3x-ui (Debug)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true",
"XUI_DB_FOLDER": "x-ui",
"XUI_LOG_FOLDER": "x-ui",
"XUI_BIN_FOLDER": "x-ui"
},
"console": "integratedTerminal"
}
]
}
```
## Working on the frontend
The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`, built with Vite 8. The sections below cover the architecture, the conventions, and the two dev workflows.
### Architecture
The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either.
### State and data flow
- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive.
- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer.
- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`.
### i18n
Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
### Two dev workflows
| Goal | Command |
|------|---------|
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there.
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
### Adding a new page
1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`).
2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
### Conventions
- **TypeScript strict mode** — all new code in `.ts` / `.tsx`. Run `npm run typecheck` (`tsc --noEmit`) before pushing. The path alias `@/*` resolves to `src/*`.
- **Ant Design 6** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back. Small, targeted UX tweaks beat sweeping rewrites; raise broader visual changes for discussion before implementing.
- **Function components + hooks** everywhere. No class components.
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes.
- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode.
### Project layout
```
frontend/
├── *.html — Vite entry HTML, one per panel route
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
├── eslint.config.js — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks)
├── vite.config.js
└── src/
├── entries/ — per-page bootstrap (createRoot + render)
├── pages/ — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
├── components/ — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
├── hooks/ — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
├── api/ — Axios setup + CSRF interceptor + WebSocket client
├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/)
├── models/ — Inbound, DBInbound, Outbound, Status, reality-targets, …
├── styles/ — shared CSS (page-cards, …)
└── utils/ — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
```
For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
## Project layout
| Path | Contents |
|------|----------|
| `main.go` | Process entry point, CLI subcommands, signal handling |
| `web/` | Gin HTTP server, controllers, services, embedded frontend assets |
| `frontend/` | React + Ant Design 6 + TypeScript source for the panel UI |
| `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
| `xray/` | Xray-core process lifecycle and gRPC API client |
| `sub/` | Subscription endpoints (raw, JSON, Clash) |
| `config/` | Environment-variable helpers, paths, defaults |
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
## Sending a pull request
1. Branch off `main` (e.g. `feat/short-description`).
2. Keep the diff focused — separate refactors from feature work.
3. Run the relevant checks before pushing:
- `go build ./...`
- `go test ./...` (when Go code changed)
- `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed)
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
5. Open the PR against `main` with a brief description of what changed and how to test it.
## Useful environment variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `XUI_DEBUG` | `false` | Verbose logs + Gin debug mode + serve `/assets` from disk |
| `XUI_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
| `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
## Issues and discussion
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
+3 -3
View File
@@ -22,7 +22,7 @@ EOF
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
[Definition]
datepattern = ^%Y/%m/%d %H:%M:%S
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
ignoreregex =
EOF
@@ -43,10 +43,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init]
name = default
+1 -1
View File
@@ -27,7 +27,7 @@ case $1 in
esac
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
+4
View File
@@ -64,6 +64,10 @@ RUN chmod +x \
/usr/bin/x-ui
ENV XUI_ENABLE_FAIL2BAN="true"
# Database backend: set XUI_DB_TYPE=postgres and XUI_DB_DSN=postgres://... to use PostgreSQL.
# Default (unset) is SQLite stored under /etc/x-ui.
ENV XUI_DB_TYPE=""
ENV XUI_DB_DSN=""
EXPOSE 2053
VOLUME [ "/etc/x-ui" ]
CMD [ "./x-ui" ]
+6
View File
@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
## أدوات المجتمع
أدوات وتكاملات بناها المجتمع حول 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (الترخيص: **MIT**): _إدارة الاتصالات الواردة والعملاء وإعدادات اللوحة وتكوين Xray كرمز باستخدام Terraform / OpenTofu._
## دعم المشروع
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
+6
View File
@@ -39,6 +39,12 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
## Herramientas de la Comunidad
Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Licencia: **MIT**): _Gestiona inbounds, clientes, configuración del panel y configuración de Xray como código con Terraform / OpenTofu._
## Apoyar el Proyecto
**Si este proyecto te es útil, puedes darle una**:star2:
+6
View File
@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنه‌های ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray به‌روزرسانی شده خودکار بر اساس داده‌های دامنه‌ها و آدرس‌های مسدود شده در روسیه است._
## ابزارهای جامعه
ابزارها و یکپارچه‌سازی‌هایی که توسط جامعه پیرامون 3x-ui ساخته شده‌اند.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (مجوز: **MIT**): _مدیریت اینباندها، کلاینت‌ها، تنظیمات پنل و پیکربندی Xray به‌صورت کد با Terraform / OpenTofu._
## پشتیبانی از پروژه
**اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید
+38
View File
@@ -30,6 +30,38 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
## Database Options
3X-UI supports two backends, chosen during the install:
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small/medium deployments.
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
At runtime the backend is selected via env vars (the installer writes these to `/etc/default/x-ui` for you):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### Migrating an existing SQLite install to PostgreSQL
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
systemctl restart x-ui
```
The source SQLite file is left untouched; remove it manually once you have verified the new backend.
### Docker
The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
```bash
docker compose --profile postgres up -d
```
## A Special Thanks to
- [alireza0](https://github.com/alireza0/)
@@ -39,6 +71,12 @@ For full documentation, please visit the [project Wiki](https://github.com/MHSan
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
## Community Tools
Tools and integrations built by the community around 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
## Support project
**If this project is helpful to you, you may wish to give it a**:star2:
+6
View File
@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
## Инструменты сообщества
Инструменты и интеграции, созданные сообществом вокруг 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Лицензия: **MIT**): _Управление входящими, клиентами, настройками панели и конфигурацией Xray через код с помощью Terraform / OpenTofu._
## Поддержка проекта
**Если этот проект полезен для вас, вы можете поставить ему**:star2:
+6
View File
@@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则,内置伊朗域名,专注于安全性和广告拦截。_
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
## 社区工具
社区围绕 3x-ui 构建的工具和集成。
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (许可证: **MIT**): _使用 Terraform / OpenTofu 通过代码管理入站、客户端、面板设置和 Xray 配置。_
## 支持项目
**如果这个项目对您有帮助,您可以给它一个**:star2:
+21
View File
@@ -57,6 +57,11 @@ func IsDebug() bool {
return os.Getenv("XUI_DEBUG") == "true"
}
// IsSkipHSTS returns true if skipping HSTS mode is enabled via the XUI_SKIP_HSTS environment variable.
func IsSkipHSTS() bool {
return os.Getenv("XUI_SKIP_HSTS") == "true"
}
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
func GetBinFolderPath() string {
binFolderPath := os.Getenv("XUI_BIN_FOLDER")
@@ -100,6 +105,22 @@ func GetDBPath() string {
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
}
// GetDBKind returns the configured database backend: "sqlite" (default) or "postgres".
func GetDBKind() string {
v := strings.ToLower(strings.TrimSpace(os.Getenv("XUI_DB_TYPE")))
switch v {
case "postgres", "postgresql", "pg":
return "postgres"
default:
return "sqlite"
}
}
// GetDBDSN returns the PostgreSQL DSN from XUI_DB_DSN. Empty for sqlite.
func GetDBDSN() string {
return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
}
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
func GetLogFolder() string {
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
+1 -1
View File
@@ -1 +1 @@
3.0.0
3.1.0
+287 -56
View File
@@ -1,15 +1,18 @@
// Package database provides database initialization, migration, and management utilities
// for the 3x-ui panel using GORM with SQLite.
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
package database
import (
"bytes"
"encoding/json"
"errors"
"io"
"log"
"os"
"path"
"slices"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/config"
@@ -17,6 +20,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/util/crypto"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -24,6 +28,27 @@ import (
var db *gorm.DB
const (
DialectSQLite = "sqlite"
DialectPostgres = "postgres"
)
// IsPostgres reports whether the active connection is a PostgreSQL backend.
func IsPostgres() bool {
if db == nil {
return config.GetDBKind() == "postgres"
}
return db.Dialector.Name() == "postgres"
}
// Dialect returns the active GORM dialect name, or "" if the DB is not open.
func Dialect() string {
if db == nil {
return ""
}
return db.Dialector.Name()
}
const (
defaultUsername = "admin"
defaultPassword = "admin"
@@ -40,9 +65,17 @@ func initModels() error {
&model.HistoryOfSeeders{},
&model.CustomGeoResource{},
&model.Node{},
&model.ApiToken{},
&model.ClientRecord{},
&model.ClientInbound{},
&model.InboundFallback{},
}
for _, model := range models {
if err := db.AutoMigrate(model); err != nil {
for _, mdl := range models {
if err := db.AutoMigrate(mdl); err != nil {
if isIgnorableDuplicateColumnErr(err, mdl) {
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
continue
}
log.Printf("Error auto migrating model: %v", err)
return err
}
@@ -50,6 +83,32 @@ func initModels() error {
return nil
}
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
if err == nil {
return false
}
errMsg := strings.ToLower(err.Error())
// SQLite: "duplicate column name: foo"
// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
const sqlitePrefix = "duplicate column name:"
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
col := strings.TrimSpace(after)
col = strings.Trim(col, "`\"[]")
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
}
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
// Best effort: extract the column name between the first pair of double quotes.
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
rest := after
if e := strings.Index(rest, "\""); e > 0 {
col := rest[:e]
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
}
}
}
return false
}
// initUser creates a default admin user if the users table is empty.
func initUser() error {
empty, err := isTableEmpty("users")
@@ -86,43 +145,195 @@ func runSeeders(isUsersEmpty bool) error {
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return db.Create(hashSeeder).Error
} else {
var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err)
if err := db.Create(hashSeeder).Error; err != nil {
return err
}
return seedApiTokens()
}
var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err)
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := db.Find(&users).Error; err != nil {
log.Printf("Error fetching users for password migration: %v", err)
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := db.Find(&users).Error; err != nil {
log.Printf("Error fetching users for password migration: %v", err)
for _, user := range users {
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err
}
for _, user := range users {
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err
}
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
log.Printf("Error updating password for user '%s': %v", user.Username, err)
return err
}
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
log.Printf("Error updating password for user '%s': %v", user.Username, err)
return err
}
}
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
return db.Create(hashSeeder).Error
hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash",
}
if err := db.Create(hashSeeder).Error; err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "ApiTokensTable") {
if err := seedApiTokens(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "ClientsTable") {
if err := seedClientsFromInboundJSON(); err != nil {
return err
}
}
return nil
}
// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
// drop the key so the field falls back to its zero value.
func normalizeClientJSONFields(obj map[string]any) {
normalizeInt := func(key string) {
raw, exists := obj[key]
if !exists {
return
}
s, ok := raw.(string)
if !ok {
return
}
trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
if trimmed == "" {
delete(obj, key)
return
}
if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
obj[key] = n
} else {
delete(obj, key)
}
}
for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} {
normalizeInt(k)
}
}
func seedClientsFromInboundJSON() error {
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
byEmail := map[string]*model.ClientRecord{}
for _, inbound := range inbounds {
if strings.TrimSpace(inbound.Settings) == "" {
continue
}
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
log.Printf("ClientsTable seed: skip inbound %d (invalid settings json): %v", inbound.Id, err)
continue
}
rawList, ok := settings["clients"].([]any)
if !ok {
continue
}
for _, raw := range rawList {
obj, ok := raw.(map[string]any)
if !ok {
continue
}
normalizeClientJSONFields(obj)
blob, err := json.Marshal(obj)
if err != nil {
continue
}
var c model.Client
if err := json.Unmarshal(blob, &c); err != nil {
log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s",
inbound.Id, err, string(blob))
continue
}
email := strings.TrimSpace(c.Email)
if email == "" {
continue
}
incoming := c.ToRecord()
row, dup := byEmail[email]
if !dup {
if err := tx.Create(incoming).Error; err != nil {
return err
}
byEmail[email] = incoming
row = incoming
} else {
conflicts := model.MergeClientRecord(row, incoming)
for _, x := range conflicts {
log.Printf("client merge: email=%s conflict on %s old=%v new=%v kept=%v",
email, x.Field, x.Old, x.New, x.Kept)
}
if err := tx.Save(row).Error; err != nil {
return err
}
}
link := model.ClientInbound{
ClientId: row.Id,
InboundId: inbound.Id,
FlowOverride: c.Flow,
}
if err := tx.Where("client_id = ? AND inbound_id = ?", row.Id, inbound.Id).
FirstOrCreate(&link).Error; err != nil {
return err
}
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "ClientsTable"}).Error
})
}
// seedApiTokens copies the legacy `apiToken` setting into the new
// api_tokens table as a row named "default" so existing central panels
// keep working after the upgrade. Idempotent — records itself in
// history_of_seeders and only runs when api_tokens is empty.
func seedApiTokens() error {
empty, err := isTableEmpty("api_tokens")
if err != nil {
return err
}
if empty {
var legacy model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
if err == nil && legacy.Value != "" {
row := &model.ApiToken{
Name: "default",
Token: legacy.Value,
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
log.Printf("Error migrating legacy apiToken: %v", err)
return err
}
}
}
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
}
// isTableEmpty returns true if the named table contains zero rows.
func isTableEmpty(tableName string) (bool, error) {
var count int64
@@ -131,43 +342,64 @@ func isTableEmpty(tableName string) (bool, error) {
}
// InitDB sets up the database connection, migrates models, and runs seeders.
// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
func InitDB(dbPath string) error {
dir := path.Dir(dbPath)
err := os.MkdirAll(dir, 0755)
if err != nil {
return err
}
var gormLogger logger.Interface
if config.IsDebug() {
gormLogger = logger.Default
gormLogger = logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
} else {
gormLogger = logger.Discard
}
c := &gorm.Config{Logger: gormLogger}
c := &gorm.Config{
Logger: gormLogger,
}
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
db, err = gorm.Open(sqlite.Open(dsn), c)
if err != nil {
return err
var err error
switch config.GetDBKind() {
case "postgres":
dsn := config.GetDBDSN()
if dsn == "" {
return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
}
db, err = gorm.Open(postgres.Open(dsn), c)
if err != nil {
return err
}
default:
dir := path.Dir(dbPath)
if err = os.MkdirAll(dir, 0755); err != nil {
return err
}
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
db, err = gorm.Open(sqlite.Open(dsn), c)
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
return err
}
}
sqlDB, err := db.DB()
if err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
return err
}
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
return err
}
sqlDB.SetMaxOpenConns(8)
sqlDB.SetMaxIdleConns(4)
sqlDB.SetConnMaxLifetime(time.Hour)
@@ -220,13 +452,12 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
}
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
// No-op on PostgreSQL (WAL there is managed by the server).
func Checkpoint() error {
// Update WAL
err := db.Exec("PRAGMA wal_checkpoint;").Error
if err != nil {
return err
if IsPostgres() {
return nil
}
return nil
return db.Exec("PRAGMA wal_checkpoint;").Error
}
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
+26
View File
@@ -0,0 +1,26 @@
package database
import "fmt"
// JSONClientsFromInbound returns the FROM clause that yields one row per element
// of inbounds.settings -> clients, with a column named `client.value` whose text
// fields can be read with JSONFieldText("client.value", "<key>").
func JSONClientsFromInbound() string {
if IsPostgres() {
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
}
return "FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client"
}
// JSONFieldText returns a SQL expression that extracts the textual value of <key>
// from a JSON expression. On both backends the result is the raw (unquoted) string,
// so callers do NOT need to trim surrounding quotes.
func JSONFieldText(expr, key string) string {
if IsPostgres() {
return fmt.Sprintf("(%s ->> '%s')", expr, key)
}
// SQLite's JSON_EXTRACT on a text value returns the JSON-encoded form
// (with surrounding quotes). Wrap it in json_extract(json_quote(...)) trick
// is fragile; simpler: unwrap quotes with TRIM(BOTH '"').
return fmt.Sprintf("TRIM(JSON_EXTRACT(%s, '$.%s'), '\"')", expr, key)
}
+143
View File
@@ -0,0 +1,143 @@
package database
import (
"errors"
"fmt"
"log"
"os"
"path"
"reflect"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// migrationModels is the FK-aware order in which tables are created and copied.
// Parents come before their children so foreign-key constraints stay satisfied
// even when checks are not explicitly disabled.
func migrationModels() []any {
return []any{
&model.User{},
&model.Setting{},
&model.HistoryOfSeeders{},
&model.CustomGeoResource{},
&model.Node{},
&model.ApiToken{},
&model.Inbound{},
&xray.ClientTraffic{},
&model.OutboundTraffics{},
&model.InboundClientIps{},
&model.ClientRecord{},
&model.ClientInbound{},
&model.InboundFallback{},
}
}
// MigrateData copies every row from the configured SQLite file at srcPath into
// a fresh PostgreSQL database described by dstDSN. The destination tables are
// (re)created with AutoMigrate before the copy. Source data is left untouched.
func MigrateData(srcPath, dstDSN string) error {
if _, err := os.Stat(srcPath); err != nil {
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
}
if dstDSN == "" {
return errors.New("destination DSN is required")
}
if err := os.MkdirAll(path.Dir(srcPath), 0755); err != nil {
return err
}
srcDSN := srcPath + "?_journal_mode=WAL&_busy_timeout=10000"
src, err := gorm.Open(sqlite.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
if err != nil {
return fmt.Errorf("open sqlite source: %w", err)
}
srcSQL, err := src.DB()
if err != nil {
return err
}
defer srcSQL.Close()
dst, err := gorm.Open(postgres.Open(dstDSN), &gorm.Config{Logger: logger.Discard})
if err != nil {
return fmt.Errorf("open postgres destination: %w", err)
}
dstSQL, err := dst.DB()
if err != nil {
return err
}
defer dstSQL.Close()
dstSQL.SetConnMaxLifetime(time.Hour)
log.Println("Creating destination schema...")
for _, m := range migrationModels() {
if err := dst.AutoMigrate(m); err != nil {
return fmt.Errorf("AutoMigrate %T: %w", m, err)
}
}
totalRows := 0
for _, m := range migrationModels() {
n, err := copyTable(src, dst, m)
if err != nil {
return fmt.Errorf("copy %T: %w", m, err)
}
totalRows += n
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
}
if err := resetPostgresSequences(dst); err != nil {
log.Printf("warning: failed to reset some postgres sequences: %v", err)
}
log.Printf("Migration complete: %d rows across %d tables.", totalRows, len(migrationModels()))
log.Println("Set XUI_DB_TYPE=postgres and XUI_DB_DSN=... in /etc/default/x-ui, then restart x-ui.")
return nil
}
// copyTable streams every row of `mdl` from src to dst in batches.
func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
sliceType := reflect.SliceOf(reflect.PointerTo(reflect.TypeOf(mdl).Elem()))
batchPtr := reflect.New(sliceType)
batchPtr.Elem().Set(reflect.MakeSlice(sliceType, 0, 0))
total := 0
err := src.Model(mdl).FindInBatches(batchPtr.Interface(), 500, func(tx *gorm.DB, _ int) error {
batch := batchPtr.Elem()
if batch.Len() == 0 {
return nil
}
if err := dst.CreateInBatches(batchPtr.Interface(), 200).Error; err != nil {
return err
}
total += batch.Len()
return nil
}).Error
return total, err
}
// resetPostgresSequences advances each table's id sequence past MAX(id),
// otherwise the next INSERT-without-id would clash with copied rows.
func resetPostgresSequences(dst *gorm.DB) error {
tables := []string{
"users", "inbounds", "outbound_traffics", "settings", "inbound_client_ips",
"client_traffics", "history_of_seeders", "custom_geo_resources", "nodes",
"api_tokens", "client_records", "client_inbounds", "inbound_fallback_children",
}
for _, t := range tables {
// setval is a no-op if the table or its id sequence doesn't exist; we ignore errors per-table.
_ = dst.Exec(fmt.Sprintf(
`SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM "%s"), 1), true)
WHERE pg_get_serial_sequence('%s','id') IS NOT NULL`,
t, t, t,
)).Error
}
return nil
}
+419 -31
View File
@@ -2,7 +2,10 @@
package model
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/mhsanaei/3x-ui/v3/util/json_util"
"github.com/mhsanaei/3x-ui/v3/xray"
@@ -21,12 +24,8 @@ const (
Shadowsocks Protocol = "shadowsocks"
Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard"
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
// settings.version to discriminate. Imports from outside the panel
// can carry the literal "hysteria2" string, so IsHysteria below
// accepts both.
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
)
// IsHysteria returns true for both "hysteria" and "hysteria2".
@@ -38,9 +37,10 @@ func IsHysteria(p Protocol) bool {
// User represents a user account in the 3x-ui panel.
type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Password string `json:"password"`
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"`
Password string `json:"password"`
LoginEpoch int64 `json:"-" gorm:"default:0"`
}
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
@@ -50,7 +50,6 @@ type Inbound struct {
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
Down int64 `json:"down" form:"down"` // Download traffic in bytes
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` // All-time traffic usage
Remark string `json:"remark" form:"remark"` // Human-readable remark
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
@@ -66,12 +65,24 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
// NodeID points at the remote panel (Node) where this inbound's xray
// actually runs. NULL means the inbound runs on the local xray (the
// pre-multi-node behaviour). Existing rows migrate to NULL with no
// backfill.
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
// FallbackParent is populated by the API layer when this inbound is
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
// The frontend uses it to rewrite client-share links so they advertise
// the master's externally reachable endpoint instead of the child's
// loopback listen. Not persisted.
FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
}
// FallbackParentInfo carries everything the frontend needs to rewrite a
// child inbound's client link: where to connect (the master's address
// and port) and which path matched on the master's fallbacks array.
// The frontend already has the master inbound in its dbInbounds list,
// so we only ship identifiers + the match path here.
type FallbackParentInfo struct {
MasterId int `json:"masterId"`
Path string `json:"path,omitempty"`
}
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
@@ -90,25 +101,132 @@ type InboundClientIps struct {
Ips string `json:"ips" form:"ips"`
}
// MarshalJSON emits the Ips column as a real JSON array instead of an escaped
// JSON-text string. Empty or unparseable storage renders as null so API
// consumers don't have to special-case the legacy double-encoded shape.
func (ic InboundClientIps) MarshalJSON() ([]byte, error) {
type alias InboundClientIps
return json.Marshal(struct {
alias
Ips json.RawMessage `json:"ips"`
}{
alias: alias(ic),
Ips: jsonStringFieldToRaw(ic.Ips),
})
}
// UnmarshalJSON accepts ips as either a JSON array (modern shape) or a
// JSON-encoded string (legacy shape), normalising back to the JSON-text the
// column stores.
func (ic *InboundClientIps) UnmarshalJSON(data []byte) error {
type alias InboundClientIps
aux := struct {
*alias
Ips json.RawMessage `json:"ips"`
}{
alias: (*alias)(ic),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
ic.Ips = jsonStringFieldFromRaw(aux.Ips)
return nil
}
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
type HistoryOfSeeders struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
SeederName string `json:"seederName"`
}
type ApiToken struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
Token string `json:"token" gorm:"not null"`
Enabled bool `json:"enabled" gorm:"default:true"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
}
// MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
// objects rather than escaped strings, so API consumers don't need to JSON.parse
// a string inside a string. Empty fields render as null; fields whose stored
// text isn't valid JSON fall back to a JSON-encoded string so no data is lost.
func (i Inbound) MarshalJSON() ([]byte, error) {
type alias Inbound
return json.Marshal(struct {
alias
Settings json.RawMessage `json:"settings"`
StreamSettings json.RawMessage `json:"streamSettings"`
Sniffing json.RawMessage `json:"sniffing"`
}{
alias: alias(i),
Settings: jsonStringFieldToRaw(i.Settings),
StreamSettings: jsonStringFieldToRaw(i.StreamSettings),
Sniffing: jsonStringFieldToRaw(i.Sniffing),
})
}
// UnmarshalJSON accepts settings, streamSettings, and sniffing as either a raw
// JSON object/array (the modern shape MarshalJSON emits) or a JSON-encoded
// string (the legacy shape). Either form is normalised back to the JSON-text
// string the DB column stores.
func (i *Inbound) UnmarshalJSON(data []byte) error {
type alias Inbound
aux := struct {
*alias
Settings json.RawMessage `json:"settings"`
StreamSettings json.RawMessage `json:"streamSettings"`
Sniffing json.RawMessage `json:"sniffing"`
}{
alias: (*alias)(i),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
i.Settings = jsonStringFieldFromRaw(aux.Settings)
i.StreamSettings = jsonStringFieldFromRaw(aux.StreamSettings)
i.Sniffing = jsonStringFieldFromRaw(aux.Sniffing)
return nil
}
func jsonStringFieldToRaw(s string) json.RawMessage {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return json.RawMessage("null")
}
if json.Valid([]byte(trimmed)) {
return json.RawMessage(trimmed)
}
b, _ := json.Marshal(s)
return b
}
func jsonStringFieldFromRaw(r json.RawMessage) string {
trimmed := bytes.TrimSpace(r)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return ""
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal(trimmed, &s); err == nil {
return s
}
}
return string(trimmed)
}
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen
// Default to 0.0.0.0 (all interfaces) when listen is empty
// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
if listen == "" {
listen = "0.0.0.0"
}
listen = fmt.Sprintf("\"%v\"", listen)
protocol := string(i.Protocol)
return &xray.InboundConfig{
Listen: json_util.RawMessage(listen),
Port: i.Port,
Protocol: string(i.Protocol),
Protocol: protocol,
Settings: json_util.RawMessage(i.Settings),
StreamSettings: json_util.RawMessage(i.StreamSettings),
Tag: i.Tag,
@@ -128,15 +246,16 @@ type Setting struct {
// endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below.
type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme"`
Address string `json:"address" form:"address"`
Port int `json:"port" form:"port"`
BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme"`
Address string `json:"address" form:"address"`
Port int `json:"port" form:"port"`
BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is
@@ -145,13 +264,19 @@ type Node struct {
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
LastError string `json:"lastError"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
InboundCount int `json:"inboundCount" gorm:"-"`
ClientCount int `json:"clientCount" gorm:"-"`
OnlineCount int `json:"onlineCount" gorm:"-"`
DepletedCount int `json:"depletedCount" gorm:"-"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
}
type CustomGeoResource struct {
@@ -162,8 +287,8 @@ type CustomGeoResource struct {
LocalPath string `json:"localPath" gorm:"column:local_path"`
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
LastModified string `json:"lastModified" gorm:"column:last_modified"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli;column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli;column:updated_at"`
}
type ClientReverse struct {
@@ -190,3 +315,266 @@ type Client struct {
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
}
type ClientRecord struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
SubID string `json:"subId" gorm:"index;column:sub_id"`
UUID string `json:"uuid" gorm:"column:uuid"`
Password string `json:"password"`
Auth string `json:"auth"`
Flow string `json:"flow"`
Security string `json:"security"`
Reverse string `json:"reverse" gorm:"column:reverse"`
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
Enable bool `json:"enable" gorm:"default:true"`
TgID int64 `json:"tgId" gorm:"column:tg_id"`
Comment string `json:"comment"`
Reset int `json:"reset" gorm:"default:0"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
}
func (ClientRecord) TableName() string { return "clients" }
// MarshalJSON emits the reverse column as a nested JSON object rather than an
// escaped JSON-text string, matching the same convention Inbound uses for its
// JSON-text columns. Empty storage renders as null.
func (r ClientRecord) MarshalJSON() ([]byte, error) {
type alias ClientRecord
return json.Marshal(struct {
alias
Reverse json.RawMessage `json:"reverse"`
}{
alias: alias(r),
Reverse: jsonStringFieldToRaw(r.Reverse),
})
}
// UnmarshalJSON accepts reverse as either a JSON object (modern shape) or a
// JSON-encoded string (legacy shape).
func (r *ClientRecord) UnmarshalJSON(data []byte) error {
type alias ClientRecord
aux := struct {
*alias
Reverse json.RawMessage `json:"reverse"`
}{
alias: (*alias)(r),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
r.Reverse = jsonStringFieldFromRaw(aux.Reverse)
return nil
}
type ClientInbound struct {
ClientId int `json:"clientId" gorm:"primaryKey;column:client_id;index"`
InboundId int `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
}
func (ClientInbound) TableName() string { return "client_inbounds" }
// InboundFallback is one routing rule on a master inbound's
// settings.fallbacks array. The master is always a VLESS or Trojan
// inbound on TCP transport with TLS or Reality. The child is any other
// inbound — its listen+port becomes the fallback dest, with optional
// SNI/ALPN/path match criteria pulled from the same row.
type InboundFallback struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
ChildId int `json:"childId" gorm:"index;not null;column:child_id"`
Name string `json:"name"`
Alpn string `json:"alpn"`
Path string `json:"path"`
Xver int `json:"xver"`
SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"`
}
func (InboundFallback) TableName() string { return "inbound_fallbacks" }
func (c *Client) ToRecord() *ClientRecord {
rec := &ClientRecord{
Email: c.Email,
SubID: c.SubID,
UUID: c.ID,
Password: c.Password,
Auth: c.Auth,
Flow: c.Flow,
Security: c.Security,
LimitIP: c.LimitIP,
TotalGB: c.TotalGB,
ExpiryTime: c.ExpiryTime,
Enable: c.Enable,
TgID: c.TgID,
Comment: c.Comment,
Reset: c.Reset,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.Reverse != nil {
if b, err := json.Marshal(c.Reverse); err == nil {
rec.Reverse = string(b)
}
}
return rec
}
func (r *ClientRecord) ToClient() *Client {
c := &Client{
ID: r.UUID,
Email: r.Email,
SubID: r.SubID,
Password: r.Password,
Auth: r.Auth,
Flow: r.Flow,
Security: r.Security,
LimitIP: r.LimitIP,
TotalGB: r.TotalGB,
ExpiryTime: r.ExpiryTime,
Enable: r.Enable,
TgID: r.TgID,
Comment: r.Comment,
Reset: r.Reset,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if r.Reverse != "" {
var rev ClientReverse
if err := json.Unmarshal([]byte(r.Reverse), &rev); err == nil {
c.Reverse = &rev
}
}
return c
}
type ClientMergeConflict struct {
Field string
Old any
New any
Kept any
}
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
var conflicts []ClientMergeConflict
keep := func(field string, oldV, newV, kept any) {
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: oldV, New: newV, Kept: kept})
}
const redacted = "<redacted>"
keepSecret := func(field string) {
conflicts = append(conflicts, ClientMergeConflict{Field: field, Old: redacted, New: redacted, Kept: redacted})
}
incomingNewer := incoming.UpdatedAt > existing.UpdatedAt ||
(incoming.UpdatedAt == existing.UpdatedAt && incoming.CreatedAt > existing.CreatedAt)
if existing.UUID != incoming.UUID && incoming.UUID != "" {
if incomingNewer || existing.UUID == "" {
existing.UUID = incoming.UUID
}
keepSecret("uuid")
}
if existing.Password != incoming.Password && incoming.Password != "" {
if incomingNewer || existing.Password == "" {
existing.Password = incoming.Password
keepSecret("password")
}
}
if existing.Auth != incoming.Auth && incoming.Auth != "" {
if incomingNewer || existing.Auth == "" {
existing.Auth = incoming.Auth
keepSecret("auth")
}
}
if existing.Flow != incoming.Flow && incoming.Flow != "" {
if incomingNewer || existing.Flow == "" {
keep("flow", existing.Flow, incoming.Flow, incoming.Flow)
existing.Flow = incoming.Flow
}
}
if existing.Security != incoming.Security && incoming.Security != "" {
if incomingNewer || existing.Security == "" {
keep("security", existing.Security, incoming.Security, incoming.Security)
existing.Security = incoming.Security
}
}
if existing.SubID != incoming.SubID && incoming.SubID != "" {
if incomingNewer || existing.SubID == "" {
existing.SubID = incoming.SubID
keepSecret("subId")
}
}
if existing.TotalGB != incoming.TotalGB {
picked := existing.TotalGB
if existing.TotalGB == 0 || (incoming.TotalGB != 0 && incoming.TotalGB > existing.TotalGB) {
picked = incoming.TotalGB
}
if picked != existing.TotalGB {
keep("totalGB", existing.TotalGB, incoming.TotalGB, picked)
existing.TotalGB = picked
}
}
if existing.ExpiryTime != incoming.ExpiryTime {
picked := existing.ExpiryTime
if existing.ExpiryTime == 0 || (incoming.ExpiryTime != 0 && incoming.ExpiryTime > existing.ExpiryTime) {
picked = incoming.ExpiryTime
}
if picked != existing.ExpiryTime {
keep("expiryTime", existing.ExpiryTime, incoming.ExpiryTime, picked)
existing.ExpiryTime = picked
}
}
if existing.LimitIP != incoming.LimitIP && incoming.LimitIP != 0 {
picked := existing.LimitIP
if existing.LimitIP == 0 || incoming.LimitIP > existing.LimitIP {
picked = incoming.LimitIP
}
if picked != existing.LimitIP {
keep("limitIp", existing.LimitIP, incoming.LimitIP, picked)
existing.LimitIP = picked
}
}
if existing.TgID != incoming.TgID && incoming.TgID != 0 {
if incomingNewer || existing.TgID == 0 {
keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)
existing.TgID = incoming.TgID
}
}
if existing.Reset != incoming.Reset && incoming.Reset != 0 {
if incomingNewer || existing.Reset == 0 {
keep("reset", existing.Reset, incoming.Reset, incoming.Reset)
existing.Reset = incoming.Reset
}
}
if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
if incomingNewer || existing.Reverse == "" {
keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
existing.Reverse = incoming.Reverse
}
}
if existing.Comment != incoming.Comment && incoming.Comment != "" {
if incomingNewer || existing.Comment == "" {
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
existing.Comment = incoming.Comment
}
}
if existing.Enable != incoming.Enable {
if incoming.Enable {
if !existing.Enable {
keep("enable", existing.Enable, incoming.Enable, true)
existing.Enable = true
}
}
}
if incoming.CreatedAt != 0 && (existing.CreatedAt == 0 || incoming.CreatedAt < existing.CreatedAt) {
existing.CreatedAt = incoming.CreatedAt
}
if incoming.UpdatedAt > existing.UpdatedAt {
existing.UpdatedAt = incoming.UpdatedAt
}
return conflicts
}
+188 -1
View File
@@ -1,6 +1,193 @@
package model
import "testing"
import (
"encoding/json"
"strings"
"testing"
)
func TestInboundMarshalJSONNestsObjectFields(t *testing.T) {
in := Inbound{
Id: 7,
Protocol: VLESS,
Port: 443,
Settings: `{"clients":[],"decryption":"none"}`,
StreamSettings: `{"network":"tcp"}`,
Sniffing: `{"enabled":true}`,
}
out, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
if _, ok := parsed[field].(map[string]any); !ok {
t.Errorf("expected %s to marshal as a JSON object, got %T", field, parsed[field])
}
}
if strings.Contains(string(out), `"settings":"`) {
t.Errorf("settings should not be emitted as a JSON string: %s", out)
}
}
func TestInboundMarshalJSONEmptyFieldsBecomeNull(t *testing.T) {
in := Inbound{Id: 1, Protocol: VLESS}
out, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
for _, field := range []string{"settings", "streamSettings", "sniffing"} {
if parsed[field] != nil {
t.Errorf("expected %s to be null, got %v", field, parsed[field])
}
}
}
func TestInboundUnmarshalJSONAcceptsBothShapes(t *testing.T) {
cases := []struct {
name string
body string
}{
{
name: "nested objects (modern)",
body: `{"id":1,"settings":{"clients":[],"decryption":"none"},"streamSettings":{"network":"tcp"},"sniffing":{"enabled":true}}`,
},
{
name: "JSON-encoded strings (legacy)",
body: `{"id":1,"settings":"{\"clients\":[],\"decryption\":\"none\"}","streamSettings":"{\"network\":\"tcp\"}","sniffing":"{\"enabled\":true}"}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var in Inbound
if err := json.Unmarshal([]byte(tc.body), &in); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !strings.Contains(in.Settings, `"decryption":"none"`) {
t.Errorf("Settings not normalised: %q", in.Settings)
}
if !strings.Contains(in.StreamSettings, `"network":"tcp"`) {
t.Errorf("StreamSettings not normalised: %q", in.StreamSettings)
}
if !strings.Contains(in.Sniffing, `"enabled":true`) {
t.Errorf("Sniffing not normalised: %q", in.Sniffing)
}
})
}
}
func TestInboundMarshalJSONInvalidTextFallsBackToString(t *testing.T) {
in := Inbound{Id: 1, Settings: "not json at all"}
out, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if !strings.Contains(string(out), `"settings":"not json at all"`) {
t.Errorf("expected invalid settings text to be wrapped as a JSON string, got %s", out)
}
}
func TestClientRecordMarshalJSONNestsReverse(t *testing.T) {
rec := ClientRecord{Id: 1, Email: "alice@example.com", Reverse: `{"tag":"vless-in"}`}
out, err := json.Marshal(rec)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
obj, ok := parsed["reverse"].(map[string]any)
if !ok {
t.Fatalf("expected reverse to marshal as a JSON object, got %T", parsed["reverse"])
}
if obj["tag"] != "vless-in" {
t.Errorf("expected tag to be preserved, got %v", obj["tag"])
}
}
func TestClientRecordMarshalJSONEmptyReverseIsNull(t *testing.T) {
rec := ClientRecord{Id: 1, Email: "alice@example.com"}
out, err := json.Marshal(rec)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if parsed["reverse"] != nil {
t.Errorf("expected reverse to be null, got %v", parsed["reverse"])
}
}
func TestClientRecordUnmarshalJSONAcceptsBothShapes(t *testing.T) {
cases := []struct {
name string
body string
}{
{name: "nested object", body: `{"id":1,"reverse":{"tag":"vless-in"}}`},
{name: "legacy string", body: `{"id":1,"reverse":"{\"tag\":\"vless-in\"}"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var rec ClientRecord
if err := json.Unmarshal([]byte(tc.body), &rec); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !strings.Contains(rec.Reverse, `"tag":"vless-in"`) {
t.Errorf("Reverse not normalised: %q", rec.Reverse)
}
})
}
}
func TestInboundClientIpsMarshalJSONNestsArray(t *testing.T) {
row := InboundClientIps{Id: 1, ClientEmail: "alice@example.com", Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
out, err := json.Marshal(row)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
arr, ok := parsed["ips"].([]any)
if !ok {
t.Fatalf("expected ips to marshal as a JSON array, got %T", parsed["ips"])
}
if len(arr) != 1 {
t.Errorf("expected 1 entry, got %d", len(arr))
}
}
func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
cases := []struct {
name string
body string
}{
{name: "nested array", body: `{"id":1,"ips":[{"ip":"1.2.3.4","timestamp":1}]}`},
{name: "legacy string", body: `{"id":1,"ips":"[{\"ip\":\"1.2.3.4\",\"timestamp\":1}]"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var row InboundClientIps
if err := json.Unmarshal([]byte(tc.body), &row); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if !strings.Contains(row.Ips, `"ip":"1.2.3.4"`) {
t.Errorf("Ips not normalised: %q", row.Ips)
}
})
}
}
func TestIsHysteria(t *testing.T) {
cases := []struct {
+19 -1
View File
@@ -11,6 +11,24 @@ services:
environment:
XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true"
# To use PostgreSQL instead of the default SQLite, run:
# docker compose --profile postgres up -d
# and uncomment the two lines below.
# XUI_DB_TYPE: "postgres"
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
tty: true
network_mode: host
ports:
- "2053:2053"
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: 3xui_postgres
profiles: ["postgres"]
environment:
POSTGRES_USER: xui
POSTGRES_PASSWORD: xui
POSTGRES_DB: xui
volumes:
- $PWD/pgdata/:/var/lib/postgresql/data
restart: unless-stopped
+22 -14
View File
@@ -1,8 +1,8 @@
# 3x-ui frontend
Vue 3 + Ant Design Vue 4 + Vite. Multi-page app — one HTML entry per
panel route — built into `../web/dist/` and embedded into the Go binary
via `embed.FS`.
React 19 + Ant Design 6 + TypeScript + Vite 8. Multi-page app — one HTML
entry per panel route — built into `../web/dist/` and embedded into the
Go binary via `embed.FS`.
## Dev
@@ -30,44 +30,52 @@ Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
`assets/`). The Go binary embeds this directory at compile time and
`web/controller/dist.go` serves the per-page HTML.
## Lint
## Type check and lint
```sh
npm run typecheck
npm run lint
```
ESLint 10 with `eslint.config.js` (flat config) — `vue3-recommended`
plus a few rule overrides for the project's formatting style.
`tsc --noEmit` against `tsconfig.json` (strict mode, `jsx: "react-jsx"`,
`@/*``src/*` alias). ESLint 10 with `eslint.config.js` (flat config)
`@eslint/js` recommended plus `typescript-eslint` and
`eslint-plugin-react-hooks` rules.
## Layout
```
frontend/
├── *.html # Vite entry HTML, one per panel route
├── tsconfig.json
├── eslint.config.js
├── vite.config.js
└── src/
├── entries/ # Per-page bootstrap (createApp + mount)
├── entries/ # Per-page bootstrap (createRoot + render)
├── pages/ # One folder per route, each with the page
│ ├── index/ # component + helpers + sub-components
│ ├── login/
│ ├── inbounds/
│ ├── clients/
│ ├── xray/
│ ├── nodes/
│ ├── settings/
│ ├── api-docs/
│ └── sub/
├── components/ # Cross-page Vue components
├── composables/ # Reusable reactive logic (useTheme, …)
├── api/ # Axios setup, CSRF interceptor
├── i18n/ # vue-i18n init (locales live in web/translation/)
├── components/ # Cross-page React components
├── hooks/ # Reusable hooks (useTheme, useWebSocket, …)
├── api/ # Axios setup, CSRF interceptor, WebSocket
├── i18n/ # react-i18next init (locales live in web/translation/)
├── models/ # Inbound, Outbound, Status, … domain classes
├── styles/ # Shared CSS modules (page-cards, …)
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
```
## Adding a new page
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.js`.
2. Add `src/entries/<page>.js` that imports the page component and
mounts it.
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.tsx`.
2. Add `src/entries/<page>.tsx` that imports the page component and
mounts it with `createRoot(...).render(...)`.
3. Add the page component under `src/pages/<page>/`.
4. Register the entry in `rollupOptions.input` in `vite.config.js`.
5. If the page is reachable from the sidebar at `/panel/<route>`, add
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API Docs</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/api-docs.tsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clients</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/clients.tsx"></script>
</body>
</html>
+44 -34
View File
@@ -1,27 +1,19 @@
import js from '@eslint/js';
import vue from 'eslint-plugin-vue';
import vueParser from 'vue-eslint-parser';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default [
{ ignores: ['node_modules/**', '../web/dist/**'] },
js.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.{js,vue}'],
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: vueParser,
parserOptions: {
ecmaFeatures: { jsx: false },
},
globals: {
...globals.browser,
...globals.node,
// Legacy script tags inject a couple of helpers on window before
// the SPA boots; declared here so no-undef stops flagging them.
getRandomRealityTarget: 'readonly',
},
},
rules: {
@@ -32,30 +24,48 @@ export default [
}],
'no-empty': ['error', { allowEmptyCatch: true }],
'no-case-declarations': 'off',
},
},
...tseslint.configs.recommended.map((config) => ({
...config,
files: ['**/*.{ts,tsx}'],
})),
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
},
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
'no-empty': ['error', { allowEmptyCatch: true }],
// Stylistic rules from vue/recommended that don't match the
// existing codebase formatting. Disable rather than churn the
// whole tree to satisfy them.
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/html-self-closing': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/html-indent': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/attributes-order': 'off',
'vue/first-attribute-linebreak': 'off',
'vue/one-component-per-file': 'off',
'vue/order-in-components': 'off',
'vue/attribute-hyphenation': 'off',
'vue/v-on-event-hyphenation': 'off',
// Pervasive in form components ported from the Vue 2 codebase
// (parent passes a reactive object; child mutates it in place).
// Properly fixing this means rewiring those components to emit
// updates — a meaningful architectural change, separate task.
'vue/no-mutating-props': 'off',
// react-hooks v7 introduces several new rules driven by the React
// Compiler. The migration uses several legitimate patterns those
// rules flag (initial-fetch in useEffect, dirty-check derived
// state, `Date.now()` inside derive helpers, inline arrow event
// handlers, in-place mutation of imported Outbound class
// instances in the OutboundFormModal). We're not running the
// compiler, so the memoization-preservation warnings have no
// effect on runtime — turning them off until the codebase
// stabilises.
'react-hooks/set-state-in-effect': 'off',
'react-hooks/purity': 'off',
'react-hooks/react-compiler': 'off',
'react-hooks/preserve-manual-memoization': 'off',
'react-hooks/immutability': 'off',
'react-hooks/refs': 'off',
},
},
];
+2 -2
View File
@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Inbounds</title>
<title>Inbounds</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/inbounds.js"></script>
<script type="module" src="/src/entries/inbounds.tsx"></script>
</body>
</html>
+2 -2
View File
@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui</title>
<title>Overview</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/index.js"></script>
<script type="module" src="/src/entries/index.tsx"></script>
</body>
</html>
+2 -2
View File
@@ -4,11 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<title>3x-ui — Sign in</title>
<title>Sign in</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/login.js"></script>
<script type="module" src="/src/entries/login.tsx"></script>
</body>
</html>
+2 -2
View File
@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Nodes</title>
<title>Nodes</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/nodes.js"></script>
<script type="module" src="/src/entries/nodes.tsx"></script>
</body>
</html>
+2353 -725
View File
File diff suppressed because it is too large Load Diff
+28 -20
View File
@@ -1,38 +1,46 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.0.1",
"version": "0.1.0",
"type": "module",
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src"
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"@ant-design/icons": "^6.2.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"antd": "^6.4.3",
"axios": "^1.16.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.20",
"moment": "^2.30.1",
"i18next": "^26.2.0",
"otpauth": "^9.5.1",
"qrious": "^4.0.2",
"qs": "^6.13.1",
"vue": "^3.5.13",
"vue-i18n": "^11.1.4",
"vue3-persian-datetime-picker": "^1.2.2"
"persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.6",
"eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"vite": "^8.0.11",
"vue-eslint-parser": "^10.4.0"
},
"overrides": {
"moment-jalaali": "^0.10.4"
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "8.0.13"
}
}
+2 -2
View File
@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Settings</title>
<title>Settings</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/settings.js"></script>
<script type="module" src="/src/entries/settings.tsx"></script>
</body>
</html>
+29 -27
View File
@@ -2,27 +2,19 @@ import axios from 'axios';
import qs from 'qs';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// Public CSRF endpoint — works pre-login (the panel-scoped
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
// login page that hasn't authenticated yet).
const CSRF_TOKEN_PATH = '/csrf-token';
// Cached session CSRF token. The legacy panel injects it via a
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
// fetch it once from /panel/csrf-token instead. Module-level so
// every axios POST sees the latest value.
let csrfToken = null;
let csrfFetchPromise = null;
let sessionExpired = false;
function readMetaToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
}
// Fetch the token via a bare fetch() (not axios) so the call doesn't
// recurse through this same interceptor.
async function fetchCsrfToken() {
try {
const basePath = window.__X_UI_BASE_PATH__;
const basePath = window.X_UI_BASE_PATH;
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
: CSRF_TOKEN_PATH);
@@ -59,7 +51,12 @@ export function setupAxios() {
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const basePath = window.__X_UI_BASE_PATH__;
// Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
let basePath = window.X_UI_BASE_PATH;
if (!basePath) {
const metaTag = document.querySelector('meta[name="base-path"]');
basePath = metaTag ? metaTag.getAttribute('content') : null;
}
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath;
}
@@ -79,7 +76,14 @@ export function setupAxios() {
if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data';
} else {
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
if (declaredType.toLowerCase().startsWith('application/json')) {
if (config.data !== undefined && typeof config.data !== 'string') {
config.data = JSON.stringify(config.data);
}
} else {
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
}
}
return config;
},
@@ -91,19 +95,12 @@ export function setupAxios() {
async (error) => {
const status = error.response?.status;
if (status === 401) {
// 401 → session is gone. In production, the panel routes
// are gated by Go's checkLogin which redirects to base_path
// serving the login page; a reload is enough. In dev, Vite
// serves /index.html directly at "/", so a reload would put
// the user right back on the dashboard and the interceptor
// would loop. Navigate to the dev login entry instead.
if (import.meta.env.DEV) {
const basePath = window.__X_UI_BASE_PATH__ || '/';
window.location.href = `${basePath}login.html`;
} else {
window.location.reload();
if (!sessionExpired) {
sessionExpired = true;
const basePath = window.X_UI_BASE_PATH || '/';
window.location.replace(basePath);
}
return Promise.reject(error);
return new Promise(() => { });
}
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
const cfg = error.config;
@@ -114,9 +111,14 @@ export function setupAxios() {
if (token) {
cfg.headers = cfg.headers || {};
cfg.headers['X-CSRF-Token'] = token;
// axios re-stringifies on retry, so unwind our qs.stringify before
// letting the same request flow through the interceptor again.
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
if (typeof cfg.data === 'string') {
if (declaredType.toLowerCase().startsWith('application/json')) {
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
} else {
cfg.data = qs.parse(cfg.data);
}
}
return axios(cfg);
}
}
+1 -1
View File
@@ -140,7 +140,7 @@ export class WebSocketClient {
#buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// basePath comes from window.__X_UI_BASE_PATH__ which is only injected
// basePath comes from window.X_UI_BASE_PATH which is only injected
// by the Go binary in production. In dev (Vite serves directly) the
// global is missing and basePath would be '' — without the fallback to
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
+287
View File
@@ -0,0 +1,287 @@
.ant-sidebar > .ant-layout-sider {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
}
.sider-brand,
.drawer-brand {
font-weight: 600;
font-size: 18px;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.88);
}
.sider-brand {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
user-select: none;
}
.sider-brand-collapsed {
justify-content: center;
font-size: 16px;
padding: 14px 4px;
letter-spacing: 0;
}
.brand-block {
display: inline-flex;
flex-direction: column;
align-items: center;
min-width: 0;
line-height: 1.1;
}
.brand-text {
display: block;
}
.brand-version {
display: block;
width: 100%;
text-align: center;
font-size: 10px;
font-weight: 500;
letter-spacing: 0;
opacity: 0.6;
margin-top: 2px;
}
.sider-brand-collapsed .brand-block {
align-items: center;
flex: 0 0 auto;
}
.brand-actions {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.sidebar-donate {
background: transparent;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.75);
text-decoration: none;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.sidebar-donate:hover,
.sidebar-donate:focus-visible {
background-color: rgba(236, 72, 153, 0.12);
color: #ec4899;
transform: scale(1.08);
outline: none;
}
.sidebar-donate .anticon {
font-size: 16px;
}
.sidebar-theme-cycle {
background: transparent;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: rgba(0, 0, 0, 0.75);
padding: 0;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.sidebar-theme-cycle:hover,
.sidebar-theme-cycle:focus-visible {
background-color: rgba(64, 150, 255, 0.1);
color: #4096ff;
transform: scale(1.08);
outline: none;
}
.sidebar-theme-cycle svg {
width: 16px;
height: 16px;
}
.drawer-header-actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.drawer-handle {
position: fixed;
top: 12px;
left: 12px;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
}
.drawer-close {
background: transparent;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
color: rgba(0, 0, 0, 0.65);
}
.drawer-close:hover,
.drawer-close:focus-visible {
background: rgba(128, 128, 128, 0.18);
}
.drawer-menu .ant-menu-item {
height: 48px;
line-height: 48px;
margin: 0;
border-radius: 0;
}
.drawer-menu .ant-menu-item .anticon {
font-size: 16px;
}
.drawer-utility {
margin-top: auto;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
}
.sider-nav {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.sider-utility {
flex: 0 0 auto;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
@media (max-width: 768px) {
.drawer-handle {
display: inline-flex;
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
display: none;
}
.ant-sidebar > .ant-layout-sider {
flex: 0 0 0 !important;
max-width: 0 !important;
min-width: 0 !important;
width: 0 !important;
}
}
body.dark .drawer-brand,
body.dark .sider-brand {
color: rgba(255, 255, 255, 0.92);
}
html[data-theme='ultra-dark'] .drawer-brand,
html[data-theme='ultra-dark'] .sider-brand {
color: #ffffff;
}
body.dark .drawer-close {
color: rgba(255, 255, 255, 0.75);
}
html[data-theme='ultra-dark'] .drawer-close {
color: rgba(255, 255, 255, 0.85);
}
body.dark .sidebar-theme-cycle {
color: rgba(255, 255, 255, 0.85);
}
html[data-theme='ultra-dark'] .sidebar-theme-cycle {
color: rgba(255, 255, 255, 0.92);
}
body.dark .sidebar-donate {
color: rgba(255, 255, 255, 0.85);
}
html[data-theme='ultra-dark'] .sidebar-donate {
color: rgba(255, 255, 255, 0.92);
}
body.dark .ant-drawer .ant-drawer-content,
body.dark .ant-drawer .ant-drawer-body {
background: #252526 !important;
}
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
background: #0a0a0a !important;
}
.sider-nav .ant-menu-item-selected,
.sider-utility .ant-menu-item-selected,
.drawer-menu .ant-menu-item-selected {
background-color: rgba(64, 150, 255, 0.2) !important;
color: #4096ff !important;
}
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
background-color: rgba(64, 150, 255, 0.1) !important;
color: #4096ff !important;
}
+290
View File
@@ -0,0 +1,290 @@
import { useCallback, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
ClusterOutlined,
CloseOutlined,
DashboardOutlined,
HeartOutlined,
LogoutOutlined,
MenuOutlined,
SettingOutlined,
TeamOutlined,
ToolOutlined,
UserOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
interface AppSidebarProps {
basePath?: string;
requestUri?: string;
}
type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
user: UserOutlined,
team: TeamOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
cluster: ClusterOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
};
function readCollapsed(): boolean {
try {
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
} catch {
return false;
}
}
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
href={DONATE_URL}
target="_blank"
rel="noopener noreferrer"
className="sidebar-donate"
aria-label={ariaLabel}
title={ariaLabel}
>
<HeartOutlined />
</a>
);
}
function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
id: string;
isDark: boolean;
isUltra: boolean;
onCycle: () => void;
ariaLabel: string;
}) {
return (
<button
id={id}
type="button"
className="sidebar-theme-cycle"
aria-label={ariaLabel}
title={ariaLabel}
onClick={onCycle}
>
{!isDark ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
) : !isUltra ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
</svg>
)}
</button>
);
}
export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [drawerOpen, setDrawerOpen] = useState(false);
const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
{ key: 'logout', icon: 'logout', title: t('logout') },
], [prefix, t]);
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
items.map((tab) => {
const Icon = iconByName[tab.icon];
return {
key: tab.key,
icon: <Icon />,
label: tab.title,
};
}),
[]);
const openLink = useCallback(async (key: string) => {
if (key === 'logout') {
await HttpUtil.post('/logout');
window.location.href = basePath || '/';
return;
}
if (key.startsWith('http')) {
window.open(key);
} else {
window.location.href = key;
}
}, [basePath]);
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
openLink(String(key));
}, [openLink]);
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
if (type === 'clickTrigger') {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
setCollapsed(isCollapsed);
}
}, []);
const cycleTheme = useCallback((id: string) => {
pauseAnimationsUntilLeave(id);
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}, [isDark, isUltra, toggleTheme, toggleUltra]);
return (
<div className="ant-sidebar">
<Layout.Sider
theme={currentTheme}
collapsible
collapsed={collapsed}
breakpoint="md"
onCollapse={onSiderCollapse}
>
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
<div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
{!collapsed && panelVersion && (
<span className="brand-version">v{panelVersion}</span>
)}
</div>
{!collapsed && (
<div className="brand-actions">
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle"
isDark={isDark}
isUltra={isUltra}
onCycle={() => cycleTheme('theme-cycle')}
ariaLabel={t('menu.theme')}
/>
</div>
)}
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[requestUri]}
className="sider-nav"
items={toMenuItems(navItems)}
onClick={onMenuClick}
/>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[requestUri]}
className="sider-utility"
items={toMenuItems(utilItems)}
onClick={onMenuClick}
/>
</Layout.Sider>
<Drawer
placement="left"
closable={false}
open={drawerOpen}
rootClassName={currentTheme}
size="min(82vw, 320px)"
styles={{
wrapper: { padding: 0 },
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
header: { display: 'none' },
}}
onClose={() => setDrawerOpen(false)}
>
<div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">3X-UI</span>
{panelVersion && <span className="brand-version">v{panelVersion}</span>}
</div>
<div className="drawer-header-actions">
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle-drawer"
isDark={isDark}
isUltra={isUltra}
onCycle={() => cycleTheme('theme-cycle-drawer')}
ariaLabel={t('menu.theme')}
/>
<button
className="drawer-close"
type="button"
aria-label={t('close')}
onClick={() => setDrawerOpen(false)}
>
<CloseOutlined />
</button>
</div>
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[requestUri]}
className="drawer-menu drawer-nav"
items={toMenuItems(navItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
/>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[requestUri]}
className="drawer-menu drawer-utility"
items={toMenuItems(utilItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
/>
</Drawer>
{!drawerOpen && (
<button
className="drawer-handle"
type="button"
aria-label={t('menu.dashboard')}
onClick={() => setDrawerOpen(true)}
>
<MenuOutlined />
</button>
)}
</div>
);
}
-163
View File
@@ -1,163 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
DashboardOutlined,
UserOutlined,
SettingOutlined,
ToolOutlined,
ClusterOutlined,
LogoutOutlined,
CloseOutlined,
MenuFoldOutlined,
} from '@ant-design/icons-vue';
import { currentTheme } from '@/composables/useTheme.js';
import ThemeSwitch from '@/components/ThemeSwitch.vue';
const { t } = useI18n();
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const props = defineProps({
// Path prefix (e.g. /custom-base/) the panel is served under. Defaults
// to '' which means tab keys end up as '/panel/...'. Pages pass the
// value the Go backend gave them (in production via a meta tag).
basePath: { type: String, default: '' },
// Current request URI so the matching menu item highlights.
requestUri: { type: String, default: '' },
});
// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
// imports — keep a small name-to-component map so tab definitions stay
// declarative.
const iconByName = {
dashboard: DashboardOutlined,
user: UserOutlined,
setting: SettingOutlined,
tool: ToolOutlined,
cluster: ClusterOutlined,
logout: LogoutOutlined,
};
// basePath comes from Go (`/` by default, `/myprefix/` when configured) so
// these concatenations land on absolute paths. In dev we synthesize the prop
// from a window global which can be empty — force a leading slash so the
// browser doesn't resolve the link relative to the current pathname (which
// would turn /panel/settings + 'panel/...' into /panel/panel/...).
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
// Labels are i18n-driven so the sidebar matches the locale picked
// in panel settings without a page reload of the sidebar component.
const tabs = computed(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
]);
const activeTab = ref([props.requestUri]);
const drawerOpen = ref(false);
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
function openLink(key) {
if (key.startsWith('http')) {
window.open(key);
} else {
window.location.href = key;
}
}
function onCollapse(isCollapsed, type) {
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
if (type === 'clickTrigger') {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
collapsed.value = isCollapsed;
}
}
function toggleDrawer() {
drawerOpen.value = !drawerOpen.value;
}
function closeDrawer() {
drawerOpen.value = false;
}
</script>
<template>
<div class="ant-sidebar">
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
<ThemeSwitch />
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
:wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
<ThemeSwitch />
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
</a-menu-item>
</a-menu>
</a-drawer>
<button class="drawer-handle" type="button" @click="toggleDrawer">
<CloseOutlined v-if="drawerOpen" />
<MenuFoldOutlined v-else />
</button>
</div>
</template>
<style scoped>
.ant-sidebar>.ant-layout-sider {
height: 100%;
}
.drawer-handle {
position: fixed;
top: 16px;
left: 16px;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
}
@media (max-width: 768px) {
.drawer-handle {
display: inline-flex;
}
/* On mobile the drawer is the menu — hide the inline sider's content
* + the collapse trigger so the sider stops taking layout space and
* leaves no remnant button next to the page. */
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
display: none;
}
.ant-sidebar>.ant-layout-sider {
flex: 0 0 0 !important;
max-width: 0 !important;
min-width: 0 !important;
width: 0 !important;
}
}
</style>
@@ -0,0 +1,52 @@
.ant-statistic-content {
font-size: 17px !important;
line-height: 1.4 !important;
font-weight: 600;
}
.ant-statistic-content-value,
.ant-statistic-content-prefix,
.ant-statistic-content-suffix {
font-size: 17px !important;
}
.ant-statistic-content-prefix {
margin-inline-end: 8px !important;
opacity: 0.7;
}
.ant-statistic-content-prefix .anticon {
font-size: 17px !important;
}
.ant-statistic-content-suffix {
font-size: 12px !important;
opacity: 0.55;
margin-inline-start: 4px;
font-weight: 500;
}
.ant-statistic-title {
font-size: 11px !important;
margin-bottom: 6px !important;
letter-spacing: 0.6px;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.55);
font-weight: 500;
}
body.dark .ant-statistic-content {
color: rgba(255, 255, 255, 0.92);
}
body.dark .ant-statistic-title {
color: rgba(255, 255, 255, 0.72);
}
html[data-theme='ultra-dark'] .ant-statistic-content {
color: rgba(255, 255, 255, 0.95);
}
html[data-theme='ultra-dark'] .ant-statistic-title {
color: rgba(255, 255, 255, 0.70);
}
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { Statistic } from 'antd';
import './CustomStatistic.css';
interface CustomStatisticProps {
title?: string;
value?: string | number;
prefix?: ReactNode;
suffix?: ReactNode;
}
export default function CustomStatistic({ title = '', value = '', prefix, suffix }: CustomStatisticProps) {
return <Statistic title={title} value={value} prefix={prefix} suffix={suffix} />;
}
@@ -1,31 +0,0 @@
<script setup>
defineProps({
title: { type: String, default: '' },
value: { type: [String, Number], default: '' },
});
</script>
<template>
<a-statistic :title="title" :value="value">
<template #prefix>
<slot name="prefix" />
</template>
<template #suffix>
<slot name="suffix" />
</template>
</a-statistic>
</template>
<style scoped>
:deep(.ant-statistic-content) {
font-size: 16px;
}
:global(body.dark .ant-statistic-content) {
color: var(--dark-color-text-primary);
}
:global(body.dark .ant-statistic-title) {
color: rgba(255, 255, 255, 0.55);
}
</style>
@@ -0,0 +1,35 @@
.jdp-wrap {
width: 100%;
}
.jdp-wrap > * {
width: 100%;
}
.jdp-wrap input {
direction: ltr;
text-align: left;
unicode-bidi: plaintext;
}
.jdp-dark .jdp-wrap input,
.jdp-dark input {
color: rgba(255, 255, 255, 0.88) !important;
background-color: #23252b !important;
}
.jdp-ultra .jdp-wrap input,
.jdp-ultra input {
color: rgba(255, 255, 255, 0.88) !important;
background-color: #101013 !important;
}
.jdp-dark input::placeholder,
.jdp-ultra input::placeholder {
color: rgba(255, 255, 255, 0.30) !important;
}
.jdp-disabled {
pointer-events: none;
opacity: 0.6;
}
@@ -0,0 +1,98 @@
import { useMemo } from 'react';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { PersianDateTimePicker } from 'persian-calendar-suite';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useTheme } from '@/hooks/useTheme';
import './DateTimePicker.css';
interface DateTimePickerProps {
value: Dayjs | null;
onChange: (next: Dayjs | null) => void;
showTime?: boolean;
format?: string;
placeholder?: string;
disabled?: boolean;
}
const LIGHT_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#ffffff',
borderColor: '#d9d9d9',
hoverColor: 'rgba(22, 119, 255, 0.10)',
selectedTextColor: '#ffffff',
textColor: 'rgba(0, 0, 0, 0.88)',
};
const DARK_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#23252b',
borderColor: 'rgba(255, 255, 255, 0.12)',
hoverColor: 'rgba(22, 119, 255, 0.18)',
selectedTextColor: '#ffffff',
textColor: 'rgba(255, 255, 255, 0.88)',
};
const ULTRA_DARK_THEME = {
primaryColor: '#1677ff',
backgroundColor: '#101013',
borderColor: 'rgba(255, 255, 255, 0.08)',
hoverColor: 'rgba(22, 119, 255, 0.16)',
selectedTextColor: '#ffffff',
textColor: 'rgba(255, 255, 255, 0.88)',
};
export default function DateTimePicker({
value,
onChange,
showTime = true,
format = 'YYYY-MM-DD HH:mm:ss',
placeholder = '',
disabled = false,
}: DateTimePickerProps) {
const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme();
const persianTheme = useMemo(() => {
if (isUltra) return ULTRA_DARK_THEME;
if (isDark) return DARK_THEME;
return LIGHT_THEME;
}, [isDark, isUltra]);
if (datepicker === 'jalalian') {
return (
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<PersianDateTimePicker
value={value ? value.valueOf() : null}
onChange={(next: number | string | null) => {
if (next == null || next === '') {
onChange(null);
return;
}
const ms = typeof next === 'number' ? next : Number(next);
if (Number.isFinite(ms)) onChange(dayjs(ms));
}}
showTime={showTime}
outputFormat="timestamp"
persianNumbers
rtlCalendar
theme={persianTheme}
/>
</div>
);
}
return (
<DatePicker
value={value}
onChange={(next) => onChange(next || null)}
showTime={showTime ? { format: 'HH:mm:ss' } : false}
format={format}
placeholder={placeholder}
disabled={disabled}
style={{ width: '100%' }}
/>
);
}
-366
View File
@@ -1,366 +0,0 @@
<script setup>
import { computed } from 'vue';
import dayjs from 'dayjs';
import PersianDatePicker from 'vue3-persian-datetime-picker';
import { useDatepicker } from '@/composables/useDatepicker.js';
// Drop-in replacement for <a-date-picker> that swaps to a real Jalali
// calendar (vue3-persian-datetime-picker, backed by moment-jalaali)
// when the panel's "Calendar Type" setting is `jalalian`.
//
// The v-model contract matches AD-Vue: the parent works with a dayjs
// object (or null). For the persian picker we serialize to/from the
// `YYYY-MM-DD HH:mm:ss` string it expects so callers don't need to
// know which renderer is active.
const props = defineProps({
value: { type: [Object, null], default: null },
showTime: { type: Boolean, default: true },
format: { type: String, default: 'YYYY-MM-DD HH:mm:ss' },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['update:value']);
const { datepicker } = useDatepicker();
const isJalali = computed(() => datepicker.value === 'jalalian');
const ISO_FORMAT = 'YYYY-MM-DD HH:mm:ss';
// Persian picker's display format `j` tokens come from moment-jalaali
// and render Jalali year/month/day.
const persianDisplayFormat = computed(() =>
props.showTime ? 'jYYYY/jMM/jDD HH:mm:ss' : 'jYYYY/jMM/jDD',
);
// Persian picker stores the date as a Gregorian string in the format
// it was given via `format`. We normalize on `YYYY-MM-DD HH:mm:ss` so
// dayjs(...) round-trips cleanly.
const stringValue = computed({
get() {
const v = props.value;
if (!v) return '';
return dayjs.isDayjs(v) ? v.format(ISO_FORMAT) : dayjs(v).format(ISO_FORMAT);
},
set(next) {
if (!next) {
emit('update:value', null);
return;
}
const parsed = dayjs(next, ISO_FORMAT);
emit('update:value', parsed.isValid() ? parsed : null);
},
});
function onAntChange(next) {
emit('update:value', next || null);
}
</script>
<template>
<PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
:placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
<a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
:placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
</template>
<style scoped>
.jalali-datepicker {
width: 100%;
}
</style>
<!-- Theme overrides for the picker. AD-Vue 4 doesn't expose CSS variables
by default (its tokens live in JS), so we hardcode hexes per theme
class `body.dark` for the navy theme, `[data-theme="ultra-dark"]`
for the neutral ultra-dark variant. The popup stays inside the
wrapper's subtree (no teleport) so global selectors reach it cleanly. -->
<style>
/* ===== Light (default) =================================================== */
.persian-datepicker-input {
width: 100%;
box-sizing: border-box;
padding: 4px 11px;
font-size: 14px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
color: rgba(0, 0, 0, 0.88);
transition: border-color 0.2s, box-shadow 0.2s;
}
.persian-datepicker-input:hover {
border-color: #4096ff;
}
.persian-datepicker-input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
outline: none;
}
/* Light theme keeps the picker's brand-blue calendar button (set via
* inline style on .vpd-icon-btn) only its border + corner radius are
* normalized so it sits flush with the input. Dark/ultra-dark themes
* below override the inline blue so the control matches the form. */
.vpd-main .vpd-icon-btn {
color: #fff;
border: 1px solid transparent;
border-radius: 6px 0 0 6px;
}
/* Match the input's left edge (no rounded left, no double border at the
* seam) so it sits flush against the icon-btn. */
.persian-datepicker-input {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.vpd-main .vpd-clear-btn {
color: rgba(0, 0, 0, 0.45);
background: transparent;
}
/* Width is exactly 316px so the 7-day grid (7 × 40px + 36px padding)
* fits flush. Don't add `border` here box-sizing: border-box would
* eat 2px from the content width and the 7th day-cell of each row
* wraps. Use box-shadow + a wider radius for the visual edge instead. */
.vpd-wrapper .vpd-content {
background: #fff;
color: rgba(0, 0, 0, 0.88);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
border-radius: 8px;
overflow: hidden;
}
.vpd-wrapper .vpd-header {
background: #1677ff;
color: #fff;
border-radius: 8px 8px 0 0;
}
.vpd-wrapper .vpd-header .vpd-year-label,
.vpd-wrapper .vpd-header .vpd-date,
.vpd-wrapper .vpd-header .vpd-locales li {
color: #fff;
}
.vpd-wrapper .vpd-body {
background: #fff;
color: rgba(0, 0, 0, 0.88);
}
.vpd-wrapper .vpd-body .vpd-month-label,
.vpd-wrapper .vpd-body .vpd-month-label>span {
color: rgba(0, 0, 0, 0.88);
}
.vpd-wrapper .vpd-body .vpd-week,
.vpd-wrapper .vpd-body .vpd-weekday {
color: rgba(0, 0, 0, 0.55);
}
.vpd-wrapper .vpd-body .vpd-controls .vpd-next,
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
color: rgba(0, 0, 0, 0.65);
}
/* The picker's <arrow> component renders an inline SVG with a hardcoded
* `fill="#000"` attribute. Override the path fill via CSS so the arrow
* is visible in every theme. */
.vpd-wrapper .vpd-next svg path,
.vpd-wrapper .vpd-prev svg path {
fill: rgba(0, 0, 0, 0.65);
}
.vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
fill: #1677ff;
}
/* The picker paints disabled days as `darken(#fff, 20%)` (~#cccccc) which
* is invisible on white and dark themes alike. Reset the day text color
* across all states so days are always readable. */
.vpd-wrapper .vpd-day,
.vpd-wrapper .vpd-day .vpd-day-text {
color: rgba(0, 0, 0, 0.88) !important;
}
.vpd-wrapper .vpd-day[disabled='true'],
.vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
color: rgba(0, 0, 0, 0.25) !important;
}
.vpd-wrapper .vpd-day:not([disabled='true']):hover .vpd-day-text,
.vpd-wrapper .vpd-day.vpd-selected .vpd-day-text {
color: #fff !important;
}
.vpd-wrapper .vpd-actions button {
color: rgba(0, 0, 0, 0.88);
background: transparent;
}
.vpd-wrapper .vpd-actions button:hover {
background: rgba(0, 0, 0, 0.04);
color: #1677ff;
}
.vpd-wrapper .vpd-addon-list,
.vpd-wrapper .vpd-addon-list-content {
background: #fff;
color: rgba(0, 0, 0, 0.88);
}
.vpd-wrapper .vpd-addon-list-item {
color: rgba(0, 0, 0, 0.88);
border-color: #fff;
}
.vpd-wrapper .vpd-addon-list-item.vpd-selected,
.vpd-wrapper .vpd-addon-list-item:hover {
background: rgba(0, 0, 0, 0.04);
}
.vpd-wrapper .vpd-close-addon {
color: rgba(0, 0, 0, 0.65);
background: rgba(0, 0, 0, 0.06);
}
/* ===== Dark (navy) ======================================================= */
body.dark .persian-datepicker-input {
background: #142340;
border-color: #1f3358;
color: rgba(255, 255, 255, 0.88);
}
body.dark .persian-datepicker-input:hover {
border-color: #4096ff;
}
body.dark .persian-datepicker-input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.18);
}
body.dark .vpd-main .vpd-icon-btn {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid #1f3358 !important;
border-right: none !important;
border-radius: 6px 0 0 6px !important;
color: rgba(255, 255, 255, 0.75) !important;
}
body.dark .vpd-wrapper .vpd-content {
background: #1a2c4d;
color: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
0 3px 6px -4px rgba(0, 0, 0, 0.48),
0 9px 28px 8px rgba(0, 0, 0, 0.2);
}
body.dark .vpd-wrapper .vpd-body {
background: #1a2c4d;
color: rgba(255, 255, 255, 0.88);
}
body.dark .vpd-wrapper .vpd-body .vpd-month-label,
body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
color: rgba(255, 255, 255, 0.88);
}
body.dark .vpd-wrapper .vpd-body .vpd-week,
body.dark .vpd-wrapper .vpd-body .vpd-weekday {
color: rgba(255, 255, 255, 0.55);
}
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next,
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
color: rgba(255, 255, 255, 0.65);
}
body.dark .vpd-wrapper .vpd-next svg path,
body.dark .vpd-wrapper .vpd-prev svg path {
fill: rgba(255, 255, 255, 0.75);
}
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
fill: #4096ff;
}
body.dark .vpd-wrapper .vpd-day,
body.dark .vpd-wrapper .vpd-day .vpd-day-text {
color: rgba(255, 255, 255, 0.88) !important;
}
body.dark .vpd-wrapper .vpd-day[disabled='true'],
body.dark .vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
color: rgba(255, 255, 255, 0.25) !important;
}
body.dark .vpd-wrapper .vpd-actions button {
color: rgba(255, 255, 255, 0.88);
}
body.dark .vpd-wrapper .vpd-actions button:hover {
background: rgba(255, 255, 255, 0.06);
}
body.dark .vpd-wrapper .vpd-addon-list,
body.dark .vpd-wrapper .vpd-addon-list-content {
background: #1a2c4d;
color: rgba(255, 255, 255, 0.88);
}
body.dark .vpd-wrapper .vpd-addon-list-item {
color: rgba(255, 255, 255, 0.88);
border-color: transparent;
}
body.dark .vpd-wrapper .vpd-addon-list-item.vpd-selected,
body.dark .vpd-wrapper .vpd-addon-list-item:hover {
background: rgba(255, 255, 255, 0.06);
}
body.dark .vpd-wrapper .vpd-close-addon {
color: rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.08);
}
/* ===== Ultra-dark (neutral black) ======================================= */
html[data-theme='ultra-dark'] .persian-datepicker-input {
background: #0a0a0a;
border-color: #303030;
color: rgba(255, 255, 255, 0.88);
}
html[data-theme='ultra-dark'] .vpd-main .vpd-icon-btn {
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid #303030 !important;
border-right: none !important;
border-radius: 6px 0 0 6px !important;
color: rgba(255, 255, 255, 0.75) !important;
}
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-content {
background: #141414;
color: rgba(255, 255, 255, 0.88);
}
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-body {
background: #141414;
}
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list,
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list-content {
background: #141414;
}
</style>
+738
View File
@@ -0,0 +1,738 @@
import { useMemo } from 'react';
import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { RandomUtil } from '@/utils';
import { Protocols } from '@/models/outbound.js';
interface StreamShape {
network?: string;
kcp?: { mtu?: number };
finalmask: {
tcp?: MaskRow[];
udp?: MaskRow[];
enableQuicParams?: boolean;
quicParams?: QuicParams;
};
addTcpMask: (type?: string) => void;
delTcpMask: (index: number) => void;
addUdpMask: (type?: string) => void;
delUdpMask: (index: number) => void;
}
interface MaskRow {
type: string;
settings: Record<string, unknown>;
_getDefaultSettings: (type: string, settings: Record<string, unknown>) => Record<string, unknown>;
}
interface ItemRow {
type: string;
packet: string | unknown[];
delay?: number | string;
rand?: number | string;
randRange?: string;
}
interface QuicParams {
congestion: string;
debug?: boolean;
brutalUp?: number | string;
brutalDown?: number | string;
hasUdpHop?: boolean;
udpHop?: { ports: string; interval: string | number };
maxIdleTimeout?: number;
keepAlivePeriod?: number;
disablePathMTUDiscovery?: boolean;
maxIncomingStreams?: number;
initStreamReceiveWindow?: number;
maxStreamReceiveWindow?: number;
initConnectionReceiveWindow?: number;
maxConnectionReceiveWindow?: number;
}
interface FinalMaskFormProps {
stream: StreamShape;
protocol: string;
onChange: () => void;
}
function changeMaskType(mask: MaskRow, type: string) {
mask.type = type;
mask.settings = mask._getDefaultSettings(type, {});
}
function changeItemType(item: ItemRow, type: string) {
item.type = type;
if (type === 'base64') item.packet = RandomUtil.randomBase64();
else if (type === 'array') {
item.rand = 0;
item.packet = [];
} else item.packet = '';
}
function newClientServerItem(): ItemRow {
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function newUdpClientServerItem(): ItemRow {
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function newNoiseItem(): ItemRow {
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
}
export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskFormProps) {
const isHysteria = protocol === Protocols.Hysteria || protocol === 'hysteria';
const network = stream?.network || '';
const showTcp = useMemo(
() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network),
[network],
);
const showUdp = isHysteria || network === 'kcp';
const showQuic = isHysteria || network === 'xhttp';
function notify() {
onChange();
}
function changeUdpMaskType(mask: MaskRow, type: string) {
changeMaskType(mask, type);
if (network === 'kcp' && stream.kcp) {
stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
}
notify();
}
function addUdpMaskWithDefault() {
const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm';
stream.addUdpMask(def);
notify();
}
const tcpMasks = stream.finalmask.tcp || [];
const udpMasks = stream.finalmask.udp || [];
if (!showTcp && !showUdp && !showQuic) return null;
return (
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
{showTcp && (
<>
<Form.Item label="TCP Masks">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => {
stream.addTcpMask('fragment');
notify();
}}
/>
</Form.Item>
{tcpMasks.map((mask, mIdx) => (
<div key={`tcp-${mIdx}`}>
<Divider style={{ margin: 0 }}>
TCP Mask {mIdx + 1}
<DeleteOutlined
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
onClick={() => {
stream.delTcpMask(mIdx);
notify();
}}
/>
</Divider>
<Form.Item label="Type">
<Select
value={mask.type}
onChange={(v) => {
changeMaskType(mask, v);
notify();
}}
options={[
{ value: 'fragment', label: 'Fragment' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'sudoku', label: 'Sudoku' },
]}
/>
</Form.Item>
{mask.type === 'fragment' && (
<>
<Form.Item label="Packets">
<Select
value={mask.settings.packets as string}
onChange={(v) => {
(mask.settings as Record<string, unknown>).packets = v;
notify();
}}
options={[
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]}
/>
</Form.Item>
{(['length', 'delay', 'maxSplit'] as const).map((field) => (
<Form.Item key={field} label={field === 'maxSplit' ? 'Max Split' : field.charAt(0).toUpperCase() + field.slice(1)}>
<Input
value={(mask.settings[field] as string) || ''}
onChange={(e) => {
(mask.settings as Record<string, unknown>)[field] = e.target.value;
notify();
}}
/>
</Form.Item>
))}
</>
)}
{mask.type === 'sudoku' && (
<>
{(['password', 'ascii', 'customTable', 'customTables'] as const).map((field) => (
<Form.Item key={field} label={field === 'customTable' ? 'Custom Table' : field === 'customTables' ? 'Custom Tables' : field.charAt(0).toUpperCase() + field.slice(1)}>
<Input
value={(mask.settings[field] as string) || ''}
onChange={(e) => {
(mask.settings as Record<string, unknown>)[field] = e.target.value;
notify();
}}
/>
</Form.Item>
))}
{(['paddingMin', 'paddingMax'] as const).map((field) => (
<Form.Item key={field} label={field === 'paddingMin' ? 'Padding Min' : 'Padding Max'}>
<InputNumber
value={(mask.settings[field] as number) || 0}
min={0}
onChange={(v) => {
(mask.settings as Record<string, unknown>)[field] = Number(v) || 0;
notify();
}}
/>
</Form.Item>
))}
</>
)}
{mask.type === 'header-custom' && (
<HeaderCustomGroups mask={mask} kind="tcp" onChange={notify} />
)}
</div>
))}
</>
)}
{showUdp && (
<>
<Form.Item label="UDP Masks">
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={addUdpMaskWithDefault} />
</Form.Item>
{udpMasks.map((mask, mIdx) => (
<div key={`udp-${mIdx}`}>
<Divider style={{ margin: 0 }}>
UDP Mask {mIdx + 1}
<DeleteOutlined
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
onClick={() => {
stream.delUdpMask(mIdx);
notify();
}}
/>
</Divider>
<Form.Item label="Type">
<Select
value={mask.type}
onChange={(v) => changeUdpMaskType(mask, v)}
options={
isHysteria
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
: [
{ value: 'mkcp-aes128gcm', label: 'mKCP AES-128-GCM' },
{ value: 'header-dns', label: 'Header DNS' },
{ value: 'header-dtls', label: 'Header DTLS 1.2' },
{ value: 'header-srtp', label: 'Header SRTP' },
{ value: 'header-utp', label: 'Header uTP' },
{ value: 'header-wechat', label: 'Header WeChat Video' },
{ value: 'header-wireguard', label: 'Header WireGuard' },
{ value: 'mkcp-original', label: 'mKCP Original' },
{ value: 'xdns', label: 'xDNS' },
{ value: 'xicmp', label: 'xICMP' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'noise', label: 'Noise' },
]
}
/>
</Form.Item>
{['mkcp-aes128gcm', 'salamander'].includes(mask.type) && (
<Form.Item label="Password">
<Input
value={(mask.settings.password as string) || ''}
placeholder="Obfuscation password"
onChange={(e) => {
(mask.settings as Record<string, unknown>).password = e.target.value;
notify();
}}
/>
</Form.Item>
)}
{mask.type === 'header-dns' && (
<Form.Item label="Domain">
<Input
value={(mask.settings.domain as string) || ''}
placeholder="e.g., www.example.com"
onChange={(e) => {
(mask.settings as Record<string, unknown>).domain = e.target.value;
notify();
}}
/>
</Form.Item>
)}
{mask.type === 'xdns' && (
<Form.Item label="Domains">
<Select
mode="tags"
value={(mask.settings.domains as string[]) || []}
style={{ width: '100%' }}
tokenSeparators={[',']}
placeholder="e.g., www.example.com"
onChange={(v) => {
(mask.settings as Record<string, unknown>).domains = v;
notify();
}}
/>
</Form.Item>
)}
{mask.type === 'noise' && (
<NoiseItems mask={mask} onChange={notify} />
)}
{mask.type === 'header-custom' && (
<UdpHeaderCustom mask={mask} onChange={notify} />
)}
{mask.type === 'xicmp' && (
<>
<Form.Item label="IP">
<Input
value={(mask.settings.ip as string) || ''}
placeholder="0.0.0.0"
onChange={(e) => {
(mask.settings as Record<string, unknown>).ip = e.target.value;
notify();
}}
/>
</Form.Item>
<Form.Item label="ID">
<InputNumber
value={(mask.settings.id as number) || 0}
min={0}
onChange={(v) => {
(mask.settings as Record<string, unknown>).id = Number(v) || 0;
notify();
}}
/>
</Form.Item>
</>
)}
</div>
))}
</>
)}
{showQuic && (
<>
<Form.Item label="QUIC Params">
<Switch
checked={!!stream.finalmask.enableQuicParams}
onChange={(v) => {
stream.finalmask.enableQuicParams = v;
notify();
}}
/>
</Form.Item>
{stream.finalmask.enableQuicParams && stream.finalmask.quicParams && (
<QuicParamsForm params={stream.finalmask.quicParams} onChange={notify} />
)}
</>
)}
</Form>
);
}
function HeaderCustomGroups({
mask,
kind: _kind,
onChange,
}: {
mask: MaskRow;
kind: 'tcp';
onChange: () => void;
}) {
const settings = mask.settings as { clients?: ItemRow[][]; servers?: ItemRow[][] };
if (!settings.clients) settings.clients = [];
if (!settings.servers) settings.servers = [];
return (
<>
{(['clients', 'servers'] as const).map((groupKey) => (
<div key={groupKey}>
<Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => {
(settings[groupKey] as ItemRow[][]).push([newClientServerItem()]);
onChange();
}}
/>
</Form.Item>
{(settings[groupKey] as ItemRow[][]).map((group, gi) => (
<div key={`${groupKey}-${gi}`}>
<Divider style={{ margin: 0 }}>
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
<DeleteOutlined
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
onClick={() => {
(settings[groupKey] as ItemRow[][]).splice(gi, 1);
onChange();
}}
/>
</Divider>
{group.map((item, _ii) => (
<ItemEditor key={_ii} item={item} onChange={onChange} delayAsNumber />
))}
</div>
))}
</div>
))}
</>
);
}
function UdpHeaderCustom({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
const settings = mask.settings as { client?: ItemRow[]; server?: ItemRow[] };
if (!settings.client) settings.client = [];
if (!settings.server) settings.server = [];
return (
<>
{(['client', 'server'] as const).map((groupKey) => (
<div key={groupKey}>
<Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => {
(settings[groupKey] as ItemRow[]).push(newUdpClientServerItem());
onChange();
}}
/>
</Form.Item>
{(settings[groupKey] as ItemRow[]).map((item, ci) => (
<div key={ci}>
<Divider style={{ margin: 0 }}>
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
<DeleteOutlined
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
onClick={() => {
(settings[groupKey] as ItemRow[]).splice(ci, 1);
onChange();
}}
/>
</Divider>
<ItemEditor item={item} onChange={onChange} />
</div>
))}
</div>
))}
</>
);
}
function NoiseItems({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
const settings = mask.settings as { reset?: number; noise?: ItemRow[] };
if (!settings.noise) settings.noise = [];
return (
<>
<Form.Item label="Reset">
<InputNumber
value={settings.reset || 0}
min={0}
onChange={(v) => {
settings.reset = Number(v) || 0;
onChange();
}}
/>
</Form.Item>
<Form.Item label="Noise">
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => {
(settings.noise as ItemRow[]).push(newNoiseItem());
onChange();
}}
/>
</Form.Item>
{(settings.noise as ItemRow[]).map((n, ni) => (
<div key={ni}>
<Divider style={{ margin: 0 }}>
Noise {ni + 1}
<DeleteOutlined
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
onClick={() => {
(settings.noise as ItemRow[]).splice(ni, 1);
onChange();
}}
/>
</Divider>
<ItemEditor item={n} onChange={onChange} delayAsString />
</div>
))}
</>
);
}
function ItemEditor({
item,
onChange,
delayAsNumber,
delayAsString,
}: {
item: ItemRow;
onChange: () => void;
delayAsNumber?: boolean;
delayAsString?: boolean;
}) {
return (
<>
<Form.Item label="Type">
<Select
value={item.type}
onChange={(v) => {
changeItemType(item, v);
onChange();
}}
options={[
{ value: 'array', label: 'Array' },
{ value: 'str', label: 'String' },
{ value: 'hex', label: 'Hex' },
{ value: 'base64', label: 'Base64' },
]}
/>
</Form.Item>
{delayAsNumber && (
<Form.Item label="Delay (ms)">
<InputNumber
value={typeof item.delay === 'number' ? item.delay : 0}
min={0}
onChange={(v) => {
item.delay = Number(v) || 0;
onChange();
}}
/>
</Form.Item>
)}
{item.type === 'array' ? (
<>
<Form.Item label="Rand">
{delayAsString ? (
<Input
value={String(item.rand ?? '')}
onChange={(e) => {
item.rand = e.target.value;
onChange();
}}
placeholder="0 or 1-8192"
/>
) : (
<InputNumber
value={typeof item.rand === 'number' ? item.rand : 0}
min={0}
onChange={(v) => {
item.rand = Number(v) || 0;
onChange();
}}
/>
)}
</Form.Item>
<Form.Item label="Rand Range">
<Input
value={item.randRange || ''}
placeholder="0-255"
onChange={(e) => {
item.randRange = e.target.value;
onChange();
}}
/>
</Form.Item>
</>
) : (
<Form.Item label="Packet">
{item.type === 'base64' ? (
<Input.Group compact>
<Input
value={String(item.packet ?? '')}
placeholder="binary data"
style={{ width: 'calc(100% - 32px)' }}
onChange={(e) => {
item.packet = e.target.value;
onChange();
}}
/>
<Button
icon={<ReloadOutlined />}
onClick={() => {
item.packet = RandomUtil.randomBase64();
onChange();
}}
/>
</Input.Group>
) : (
<Input
value={String(item.packet ?? '')}
placeholder="binary data"
onChange={(e) => {
item.packet = e.target.value;
onChange();
}}
/>
)}
</Form.Item>
)}
{delayAsString && (
<Form.Item label="Delay">
<Input
value={typeof item.delay === 'string' ? item.delay : ''}
placeholder="10-20"
onChange={(e) => {
item.delay = e.target.value;
onChange();
}}
/>
</Form.Item>
)}
</>
);
}
function QuicParamsForm({ params, onChange }: { params: QuicParams; onChange: () => void }) {
function update<K extends keyof QuicParams>(key: K, value: QuicParams[K]) {
params[key] = value;
onChange();
}
return (
<>
<Form.Item label="Congestion">
<Select
value={params.congestion}
onChange={(v) => update('congestion', v)}
options={[
{ value: 'reno', label: 'Reno' },
{ value: 'bbr', label: 'BBR' },
{ value: 'brutal', label: 'Brutal' },
{ value: 'force-brutal', label: 'Force Brutal' },
]}
/>
</Form.Item>
<Form.Item label="Debug">
<Switch checked={!!params.debug} onChange={(v) => update('debug', v)} />
</Form.Item>
{['brutal', 'force-brutal'].includes(params.congestion) && (
<>
<Form.Item label="Brutal Up">
<Input
value={String(params.brutalUp ?? '')}
placeholder="65537"
onChange={(e) => update('brutalUp', e.target.value)}
/>
</Form.Item>
<Form.Item label="Brutal Down">
<Input
value={String(params.brutalDown ?? '')}
placeholder="65537"
onChange={(e) => update('brutalDown', e.target.value)}
/>
</Form.Item>
</>
)}
<Form.Item label="UDP Hop">
<Switch checked={!!params.hasUdpHop} onChange={(v) => update('hasUdpHop', v)} />
</Form.Item>
{params.hasUdpHop && params.udpHop && (
<>
<Form.Item label="Hop Ports">
<Input
value={params.udpHop.ports || ''}
placeholder="e.g. 20000-50000"
onChange={(e) => {
params.udpHop!.ports = e.target.value;
onChange();
}}
/>
</Form.Item>
<Form.Item label="Hop Interval (s)">
<InputNumber
value={Number(params.udpHop.interval) || 5}
min={5}
onChange={(v) => {
params.udpHop!.interval = Number(v) || 5;
onChange();
}}
/>
</Form.Item>
</>
)}
{(
[
['maxIdleTimeout', 'Max Idle Timeout (s)', 4, 120],
['keepAlivePeriod', 'Keep Alive Period (s)', 2, 60],
] as const
).map(([key, label, min, max]) => (
<Form.Item key={key} label={label}>
<InputNumber
value={params[key] as number}
min={min}
max={max}
onChange={(v) => update(key, Number(v) || min)}
/>
</Form.Item>
))}
<Form.Item label="Disable Path MTU Dis">
<Switch checked={!!params.disablePathMTUDiscovery} onChange={(v) => update('disablePathMTUDiscovery', v)} />
</Form.Item>
{(
[
['maxIncomingStreams', 'Max Incoming Streams', 8, '1024 = default'],
['initStreamReceiveWindow', 'Init Stream Window', 16384, '8388608 = default'],
['maxStreamReceiveWindow', 'Max Stream Window', 16384, '8388608 = default'],
['initConnectionReceiveWindow', 'Init Conn Window', 16384, '20971520 = default'],
['maxConnectionReceiveWindow', 'Max Conn Window', 16384, '20971520 = default'],
] as const
).map(([key, label, min, placeholder]) => (
<Form.Item key={key} label={label}>
<InputNumber
value={params[key] as number}
min={min}
placeholder={placeholder}
onChange={(v) => update(key, Number(v) || 0)}
/>
</Form.Item>
))}
</>
);
}
-510
View File
@@ -1,510 +0,0 @@
<script setup>
import { computed } from 'vue';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { RandomUtil } from '@/utils';
import { Protocols } from '@/models/inbound.js';
// Mirrors web/html/form/stream/stream_finalmask.html. Used by both the
// inbound and outbound modals they share the same StreamSettings
// shape (`stream.finalmask`, `stream.addTcpMask()`, etc.) so a single
// component handles both. The host modal passes its protocol through
// so we know whether to show only the Hysteria-specific UDP types.
const props = defineProps({
stream: { type: Object, required: true },
protocol: { type: String, default: '' },
});
const isHysteria = computed(() => props.protocol === Protocols.HYSTERIA);
const network = computed(() => props.stream?.network || '');
const showTcp = computed(() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network.value));
const showUdp = computed(() => isHysteria.value || network.value === 'kcp');
const showQuic = computed(() => isHysteria.value || network.value === 'xhttp');
// Reset the per-row settings shape when the user picks a different
// type mirrors the legacy `mask._getDefaultSettings(type, {})` call.
function changeMaskType(mask, type) {
mask.type = type;
mask.settings = mask._getDefaultSettings(type, {});
}
// Special case from the legacy form: switching a UDP mask to xdns
// shrinks the kcp MTU; everything else needs the default 1350.
function changeUdpMaskType(mask, type) {
changeMaskType(mask, type);
if (network.value === 'kcp' && props.stream.kcp) {
props.stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
}
}
// header-custom and noise rows share the same per-item shape the
// type select rewires the packet field. Pulled out so the click
// handlers in the template stay readable.
function changeItemType(item, type) {
item.type = type;
if (type === 'base64') item.packet = RandomUtil.randomBase64();
else if (type === 'array') { item.rand = 0; item.packet = []; }
else item.packet = '';
}
function addUdpMaskWithDefault() {
const def = isHysteria.value ? 'salamander' : 'mkcp-aes128gcm';
props.stream.addUdpMask(def);
}
function newClientServerItem() {
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function newUdpClientServerItem() {
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
}
function newNoiseItem() {
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
}
</script>
<template>
<a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }">
<!-- ============================== TCP MASKS ============================== -->
<template v-if="showTcp">
<a-form-item label="TCP Masks">
<a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
<a-divider :style="{ margin: '0' }">
TCP Mask {{ mIdx + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="stream.delTcpMask(mIdx)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="mask.type" @change="(t) => changeMaskType(mask, t)">
<a-select-option value="fragment">Fragment</a-select-option>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="sudoku">Sudoku</a-select-option>
</a-select>
</a-form-item>
<!-- Fragment -->
<template v-if="mask.type === 'fragment'">
<a-form-item label="Packets">
<a-select v-model:value="mask.settings.packets">
<a-select-option value="tlshello">tlshello</a-select-option>
<a-select-option value="1-3">1-3</a-select-option>
<a-select-option value="1-5">1-5</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Length">
<a-input v-model:value="mask.settings.length" placeholder="e.g. 100-200" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model:value="mask.settings.delay" placeholder="e.g. 10-20" />
</a-form-item>
<a-form-item label="Max Split">
<a-input v-model:value="mask.settings.maxSplit" placeholder="e.g. 3-6" />
</a-form-item>
</template>
<!-- Sudoku -->
<template v-if="mask.type === 'sudoku'">
<a-form-item label="Password">
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
</a-form-item>
<a-form-item label="ASCII">
<a-input v-model:value="mask.settings.ascii" placeholder="ASCII" />
</a-form-item>
<a-form-item label="Custom Table">
<a-input v-model:value="mask.settings.customTable" placeholder="Custom Table" />
</a-form-item>
<a-form-item label="Custom Tables">
<a-input v-model:value="mask.settings.customTables" placeholder="Custom Tables" />
</a-form-item>
<a-form-item label="Padding Min">
<a-input-number v-model:value="mask.settings.paddingMin" :min="0" />
</a-form-item>
<a-form-item label="Padding Max">
<a-input-number v-model:value="mask.settings.paddingMax" :min="0" />
</a-form-item>
</template>
<!-- Header Custom clients/servers as 2D groups -->
<template v-if="mask.type === 'header-custom'">
<!-- Clients -->
<a-form-item label="Clients">
<a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
<a-divider :style="{ margin: '0' }">
Clients Group {{ gi + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.clients.splice(gi, 1)" />
</a-divider>
<template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
<a-form-item label="Type">
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model:value="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model:value="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="item.type === 'base64'" compact>
<a-input v-model:value="item.packet" placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="item.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<!-- Servers -->
<a-form-item label="Servers">
<a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
<a-divider :style="{ margin: '0' }">
Servers Group {{ gi + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.servers.splice(gi, 1)" />
</a-divider>
<template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
<a-form-item label="Type">
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model:value="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model:value="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="item.type === 'base64'" compact>
<a-input v-model:value="item.packet" placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="item.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
</template>
</template>
</template>
<!-- ============================== UDP MASKS ============================== -->
<template v-if="showUdp">
<a-form-item label="UDP Masks">
<a-button type="primary" size="small" @click="addUdpMaskWithDefault">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
<a-divider :style="{ margin: '0' }">
UDP Mask {{ mIdx + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="stream.delUdpMask(mIdx)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="mask.type" @change="(t) => changeUdpMaskType(mask, t)">
<template v-if="isHysteria">
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
</template>
<template v-else>
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
<a-select-option value="header-dns">Header DNS</a-select-option>
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
<a-select-option value="header-srtp">Header SRTP</a-select-option>
<a-select-option value="header-utp">Header uTP</a-select-option>
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
<a-select-option value="xdns">xDNS</a-select-option>
<a-select-option value="xicmp">xICMP</a-select-option>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="noise">Noise</a-select-option>
</template>
</a-select>
</a-form-item>
<a-form-item v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)" label="Password">
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
</a-form-item>
<a-form-item v-if="mask.type === 'header-dns'" label="Domain">
<a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
</a-form-item>
<a-form-item v-if="mask.type === 'xdns'" label="Domains">
<a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
:token-separators="[',']" placeholder="e.g., www.example.com" />
</a-form-item>
<!-- Noise -->
<template v-if="mask.type === 'noise'">
<a-form-item label="Reset">
<a-input-number v-model:value="mask.settings.reset" :min="0" />
</a-form-item>
<a-form-item label="Noise">
<a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
<a-divider :style="{ margin: '0' }">
Noise {{ ni + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.noise.splice(ni, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="n.type" @change="(t) => changeItemType(n, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="n.type === 'array'">
<a-form-item label="Rand">
<a-input v-model:value="n.rand" placeholder="0 or 1-8192" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="n.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="n.type === 'base64'" compact>
<a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="n.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="n.packet" placeholder="binary data" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model:value="n.delay" placeholder="10-20" />
</a-form-item>
</template>
</template>
<!-- Header Custom (UDP) flat client/server lists -->
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
<a-divider :style="{ margin: '0' }">
Client {{ ci + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.client.splice(ci, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="c.type" @change="(t) => changeItemType(c, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="c.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model:value="c.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="c.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="c.type === 'base64'" compact>
<a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="c.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="c.packet" placeholder="binary data" />
</a-form-item>
</template>
<a-divider :style="{ margin: '0' }" />
<a-form-item label="Server">
<a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
<a-divider :style="{ margin: '0' }">
Server {{ si + 1 }}
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.server.splice(si, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="s.type" @change="(t) => changeItemType(s, t)">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="s.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model:value="s.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model:value="s.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="s.type === 'base64'" compact>
<a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="s.packet = RandomUtil.randomBase64()">
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="s.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<!-- xICMP -->
<template v-if="mask.type === 'xicmp'">
<a-form-item label="IP">
<a-input v-model:value="mask.settings.ip" placeholder="0.0.0.0" />
</a-form-item>
<a-form-item label="ID">
<a-input-number v-model:value="mask.settings.id" :min="0" />
</a-form-item>
</template>
</template>
</template>
<!-- ============================== QUIC PARAMS ============================== -->
<template v-if="showQuic">
<a-form-item label="QUIC Params">
<a-switch v-model:checked="stream.finalmask.enableQuicParams" />
</a-form-item>
<template v-if="stream.finalmask.enableQuicParams && stream.finalmask.quicParams">
<a-form-item label="Congestion">
<a-select v-model:value="stream.finalmask.quicParams.congestion">
<a-select-option value="reno">Reno</a-select-option>
<a-select-option value="bbr">BBR</a-select-option>
<a-select-option value="brutal">Brutal</a-select-option>
<a-select-option value="force-brutal">Force Brutal</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Debug">
<a-switch v-model:checked="stream.finalmask.quicParams.debug" />
</a-form-item>
<template v-if="['brutal', 'force-brutal'].includes(stream.finalmask.quicParams.congestion)">
<a-form-item label="Brutal Up">
<a-input v-model:value="stream.finalmask.quicParams.brutalUp" placeholder="65537" />
</a-form-item>
<a-form-item label="Brutal Down">
<a-input v-model:value="stream.finalmask.quicParams.brutalDown" placeholder="65537" />
</a-form-item>
</template>
<a-form-item label="UDP Hop">
<a-switch v-model:checked="stream.finalmask.quicParams.hasUdpHop" />
</a-form-item>
<template v-if="stream.finalmask.quicParams.hasUdpHop && stream.finalmask.quicParams.udpHop">
<a-form-item label="Hop Ports">
<a-input v-model:value="stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
</a-form-item>
<a-form-item label="Hop Interval (s)">
<a-input-number v-model:value="stream.finalmask.quicParams.udpHop.interval" :min="5" />
</a-form-item>
</template>
<a-form-item label="Max Idle Timeout (s)">
<a-input-number v-model:value="stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
</a-form-item>
<a-form-item label="Keep Alive Period (s)">
<a-input-number v-model:value="stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
</a-form-item>
<a-form-item label="Disable Path MTU Dis">
<a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
</a-form-item>
<a-form-item label="Max Incoming Streams">
<a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
placeholder="1024 = default" />
</a-form-item>
<a-form-item label="Init Stream Window">
<a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
placeholder="8388608 = default" />
</a-form-item>
<a-form-item label="Max Stream Window">
<a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
placeholder="8388608 = default" />
</a-form-item>
<a-form-item label="Init Conn Window">
<a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
placeholder="20971520 = default" />
</a-form-item>
<a-form-item label="Max Conn Window">
<a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
placeholder="20971520 = default" />
</a-form-item>
</template>
</template>
</a-form>
</template>
+19
View File
@@ -0,0 +1,19 @@
interface InfinityIconProps {
width?: number | string;
height?: number | string;
}
export default function InfinityIcon({ width = 14, height = 10 }: InfinityIconProps) {
return (
<svg
width={width}
height={height}
viewBox="0 0 640 512"
fill="currentColor"
aria-hidden="true"
style={{ verticalAlign: '-1px', display: 'inline-block' }}
>
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
</svg>
);
}
-18
View File
@@ -1,18 +0,0 @@
<script setup>
// Inline SVG. The Unicode infinity character (U+221E) renders as an
// "m"-shaped glyph in some system fonts (Windows Segoe UI in particular),
// so the inbound list and client row table use this SVG instead. The
// path matches what the legacy panel embedded.
defineProps({
width: { type: [String, Number], default: 14 },
height: { type: [String, Number], default: 10 },
});
</script>
<template>
<svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
style="vertical-align: -1px; display: inline-block;">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
</svg>
</template>
+40
View File
@@ -0,0 +1,40 @@
.input-addon {
display: inline-flex;
align-items: center;
padding: 0 11px;
height: 32px;
font-size: 14px;
line-height: 30px;
background-color: rgba(0, 0, 0, 0.02);
border: 1px solid #d9d9d9;
border-radius: 6px;
position: relative;
z-index: 1;
color: rgba(0, 0, 0, 0.88);
white-space: nowrap;
}
body.dark .input-addon,
html[data-theme='ultra-dark'] .input-addon {
background-color: rgba(255, 255, 255, 0.04);
border-color: #424242;
color: rgba(255, 255, 255, 0.85);
}
.ant-space-compact > .input-addon:not(:first-child) {
margin-inline-start: -1px;
}
.ant-space-compact > .input-addon:first-child {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.ant-space-compact > .input-addon:last-child {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.ant-space-compact > .input-addon:not(:first-child):not(:last-child) {
border-radius: 0;
}
+21
View File
@@ -0,0 +1,21 @@
import type { CSSProperties, ReactNode } from 'react';
import './InputAddon.css';
interface InputAddonProps {
children: ReactNode;
className?: string;
style?: CSSProperties;
onClick?: () => void;
}
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
return (
<span
className={`input-addon ${className}`.trim()}
style={style}
onClick={onClick}
>
{children}
</span>
);
}
+26
View File
@@ -0,0 +1,26 @@
.json-editor-host {
border: 1px solid var(--ant-color-border, #d9d9d9);
border-radius: 6px;
overflow: hidden;
background: var(--ant-color-bg-container, #fff);
}
.json-editor-host .cm-editor,
.json-editor-host .cm-editor.cm-focused {
outline: none;
}
.json-editor-host:focus-within {
border-color: var(--ant-color-primary, #1677ff);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
body.dark .json-editor-host {
border-color: #3a3a3c;
background: #1e1e1e;
}
html[data-theme="ultra-dark"] .json-editor-host {
border-color: #1f1f1f;
background: #0a0a0a;
}
+179
View File
@@ -0,0 +1,179 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state';
import { json, jsonParseLinter } from '@codemirror/lang-json';
import { lintGutter, linter } from '@codemirror/lint';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { syntaxHighlighting } from '@codemirror/language';
import { keymap } from '@codemirror/view';
import { indentWithTab } from '@codemirror/commands';
import { useTheme } from '@/hooks/useTheme';
import './JsonEditor.css';
export interface JsonEditorProps {
value: string;
onChange?: (next: string) => void;
minHeight?: string;
maxHeight?: string;
readOnly?: boolean;
}
export interface JsonEditorHandle {
focus: () => void;
}
interface DarkPalette {
bg: string;
panelBg: string;
activeBg: string;
border: string;
selection: string;
}
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) {
return EditorView.theme(
{
'&': { color: '#dcdcdc', backgroundColor: bg },
'.cm-content': { caretColor: '#dcdcdc' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
'.cm-gutters': {
backgroundColor: bg,
borderRight: `1px solid ${border}`,
color: '#6a6a6a',
},
'.cm-activeLine': { backgroundColor: activeBg },
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{ backgroundColor: selection },
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
'.cm-tooltip': {
backgroundColor: panelBg,
border: `1px solid ${border}`,
color: '#dcdcdc',
},
},
{ dark: true },
);
}
const darkTheme = buildDarkTheme({
bg: '#1e1e1e',
panelBg: '#2d2d30',
activeBg: '#252526',
border: '#3a3a3c',
selection: '#3a3a3c',
});
const ultraDarkTheme = buildDarkTheme({
bg: '#0a0a0a',
panelBg: '#141414',
activeBg: '#141414',
border: '#1f1f1f',
selection: '#2a2a2a',
});
function themeExtension(isDark: boolean, isUltra: boolean) {
if (!isDark) return [];
const chrome = isUltra ? ultraDarkTheme : darkTheme;
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
}
const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
{ value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
ref,
) {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const themeCompartmentRef = useRef<Compartment>(new Compartment());
const readonlyCompartmentRef = useRef<Compartment>(new Compartment());
const onChangeRef = useRef(onChange);
const valueRef = useRef(value);
const { isDark, isUltra } = useTheme();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useImperativeHandle(ref, () => ({
focus: () => viewRef.current?.focus(),
}));
useEffect(() => {
if (!hostRef.current) return;
const updateListener = EditorView.updateListener.of((u) => {
if (!u.docChanged) return;
const next = u.state.doc.toString();
if (next === valueRef.current) return;
valueRef.current = next;
onChangeRef.current?.(next);
});
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: value,
extensions: [
basicSetup,
keymap.of([indentWithTab]),
json(),
linter(jsonParseLinter()),
lintGutter(),
EditorView.lineWrapping,
updateListener,
themeCompartmentRef.current.of(themeExtension(isDark, isUltra)),
readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
EditorView.theme({
'&': { height: '100%' },
'.cm-scroller': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '12px',
minHeight,
maxHeight,
},
}),
],
}),
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (value === current) return;
valueRef.current = value;
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
}, [value]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)),
});
}, [isDark, isUltra]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
});
}, [readOnly]);
return <div ref={hostRef} className="json-editor-host" />;
});
export default JsonEditor;
+20
View File
@@ -0,0 +1,20 @@
import { Suspense, useEffect, useState, type ReactNode } from 'react';
interface LazyMountProps {
when: boolean;
fallback?: ReactNode;
children: ReactNode;
}
// Mounts children only after `when` first becomes true and keeps them mounted
// thereafter, so React.lazy modals get loaded on demand but their close
// animations still play out. Pair with `lazy(() => import(...))` modal imports
// on heavy list pages to keep the initial bundle small.
export default function LazyMount({ when, fallback = null, children }: LazyMountProps) {
const [mounted, setMounted] = useState(when);
useEffect(() => {
if (when && !mounted) setMounted(true);
}, [when, mounted]);
if (!mounted) return null;
return <Suspense fallback={fallback}>{children}</Suspense>;
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from 'react';
import { Input, Modal } from 'antd';
import type { InputRef } from 'antd';
interface PromptModalProps {
open: boolean;
onClose: () => void;
title: string;
okText?: string;
type?: 'input' | 'textarea';
initialValue?: string;
loading?: boolean;
onConfirm: (value: string) => void;
}
export default function PromptModal({
open,
onClose,
title,
okText = 'OK',
type = 'input',
initialValue = '',
loading = false,
onConfirm,
}: PromptModalProps) {
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const inputRef = useRef<InputRef | null>(null);
useEffect(() => {
if (open) {
setValue(initialValue);
setTimeout(() => {
if (type === 'textarea') textareaRef.current?.focus();
else inputRef.current?.focus();
}, 50);
}
}, [open, initialValue, type]);
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
if (type !== 'textarea' && e.key === 'Enter') {
e.preventDefault();
onConfirm(value);
return;
}
if (type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault();
onConfirm(value);
}
}
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText="Cancel"
mask={{ closable: false }}
confirmLoading={loading}
onOk={() => onConfirm(value)}
onCancel={onClose}
destroyOnHidden
>
{type === 'textarea' ? (
<Input.TextArea
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
value={value}
onChange={(e) => setValue(e.target.value)}
autoSize={{ minRows: 10, maxRows: 20 }}
onKeyDown={onKeydown}
/>
) : (
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeydown}
/>
)}
</Modal>
);
}
-52
View File
@@ -1,52 +0,0 @@
<script setup>
import { ref, watch } from 'vue';
// Generic prompt modal used by features like "import inbound" that
// need a free-form text/textarea input and a confirm callback. The
// parent owns the action; this component only surfaces the value via
// the `confirm` event when the user clicks OK.
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
okText: { type: String, default: 'OK' },
// 'text' = single-line input; 'textarea' = multi-line.
type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
initialValue: { type: String, default: '' },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open', 'confirm']);
const value = ref('');
watch(() => props.open, (next) => {
if (next) value.value = props.initialValue;
});
function close() { emit('update:open', false); }
function ok() { emit('confirm', value.value); }
// Enter submits when single-line; ctrl+S submits in textarea mode
// (matches legacy keybindings).
function onKeydown(e) {
if (props.type !== 'textarea' && e.key === 'Enter') {
e.preventDefault();
ok();
return;
}
if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault();
ok();
}
}
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
:confirm-loading="loading" @ok="ok" @cancel="close">
<a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
@keydown="onKeydown" />
<a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
</a-modal>
</template>
@@ -0,0 +1,43 @@
.setting-list-item {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.setting-list-item:last-child {
border-bottom: 0;
}
body.dark .setting-list-item,
html[data-theme='ultra-dark'] .setting-list-item {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.setting-list-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-list-title {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
.setting-list-description {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5715;
}
body.dark .setting-list-title,
html[data-theme='ultra-dark'] .setting-list-title {
color: rgba(255, 255, 255, 0.85);
}
body.dark .setting-list-description,
html[data-theme='ultra-dark'] .setting-list-description {
color: rgba(255, 255, 255, 0.45);
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { Col, Row } from 'antd';
import './SettingListItem.css';
interface SettingListItemProps {
paddings?: 'small' | 'default';
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
control?: ReactNode;
}
export default function SettingListItem({
paddings = 'default',
title,
description,
children,
control,
}: SettingListItemProps) {
const padding = paddings === 'small' ? '10px 20px' : '20px';
return (
<div className="setting-list-item" style={{ padding }}>
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}>
<div className="setting-list-meta">
{title && <div className="setting-list-title">{title}</div>}
{description && <div className="setting-list-description">{description}</div>}
</div>
</Col>
<Col xs={24} lg={12}>
{control ?? children}
</Col>
</Row>
</div>
);
}
@@ -1,35 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
paddings: {
type: String,
default: 'default',
validator: (value) => ['small', 'default'].includes(value),
},
});
const padding = computed(() =>
props.paddings === 'small' ? '10px 20px !important' : '20px !important',
);
</script>
<template>
<a-list-item :style="{ padding }">
<a-row :gutter="[8, 16]">
<a-col :xs="24" :lg="12">
<a-list-item-meta>
<template #title>
<slot name="title" />
</template>
<template #description>
<slot name="description" />
</template>
</a-list-item-meta>
</a-col>
<a-col :xs="24" :lg="12">
<slot name="control" />
</a-col>
</a-row>
</a-list-item>
</template>
+44
View File
@@ -0,0 +1,44 @@
.sparkline-svg {
display: block;
width: 100%;
}
.sparkline-svg .cpu-grid-y-text,
.sparkline-svg .cpu-grid-x-text {
fill: rgba(0, 0, 0, 0.55);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
letter-spacing: 0.2px;
}
.sparkline-svg .cpu-grid-text {
fill: rgba(0, 0, 0, 0.88);
}
.sparkline-svg .cpu-grid-line {
stroke: rgba(0, 0, 0, 0.08);
}
.sparkline-svg .cpu-tooltip-text {
pointer-events: none;
}
.sparkline-svg .cpu-tooltip-pill {
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
}
body.dark .sparkline-svg .cpu-grid-y-text,
body.dark .sparkline-svg .cpu-grid-x-text {
fill: rgba(255, 255, 255, 0.7);
}
body.dark .sparkline-svg .cpu-grid-text {
fill: rgba(255, 255, 255, 0.95);
}
body.dark .sparkline-svg .cpu-grid-line {
stroke: rgba(255, 255, 255, 0.10);
}
body.dark .sparkline-svg .cpu-tooltip-pill {
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.6));
}
+368
View File
@@ -0,0 +1,368 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import './Sparkline.css';
interface SparklineProps {
data: number[];
labels?: (string | number)[];
vbWidth?: number;
height?: number;
stroke?: string;
strokeWidth?: number;
maxPoints?: number;
showGrid?: boolean;
gridColor?: string;
fillOpacity?: number;
showMarker?: boolean;
markerRadius?: number;
showAxes?: boolean;
yTickStep?: number;
tickCountX?: number;
paddingLeft?: number;
paddingRight?: number;
paddingTop?: number;
paddingBottom?: number;
showTooltip?: boolean;
valueMin?: number;
valueMax?: number | null;
yFormatter?: (v: number) => string;
tooltipFormatter?: ((v: number) => string) | null;
}
export default function Sparkline({
data,
labels = [],
vbWidth = 320,
height = 80,
stroke = '#008771',
strokeWidth = 2,
maxPoints = 120,
showGrid = true,
gridColor = 'rgba(0,0,0,0.08)',
fillOpacity = 0.22,
showMarker = true,
markerRadius = 3,
showAxes = false,
yTickStep = 25,
tickCountX = 4,
paddingLeft = 56,
paddingRight = 6,
paddingTop = 6,
paddingBottom = 20,
showTooltip = false,
valueMin = 0,
valueMax = 100,
yFormatter = (v: number) => `${Math.round(v)}%`,
tooltipFormatter = null,
}: SparklineProps) {
const svgRef = useRef<SVGSVGElement | null>(null);
const [measuredWidth, setMeasuredWidth] = useState(0);
const [hoverIdx, setHoverIdx] = useState(-1);
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
const gradId = `spkGrad-${safeId}`;
const shadowId = `spkShadow-${safeId}`;
const glowId = `spkGlow-${safeId}`;
useEffect(() => {
const el = svgRef.current;
if (!el) return;
const measure = () => {
const w = el.getBoundingClientRect?.().width || 0;
if (w > 0) setMeasuredWidth(Math.round(w));
};
measure();
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth;
const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight);
const drawHeight = Math.max(1, height - paddingTop - paddingBottom);
const nPoints = Math.min(data.length, maxPoints);
const dataSlice = useMemo(
() => (nPoints === 0 ? [] : data.slice(data.length - nPoints)),
[data, nPoints],
);
const labelsSlice = useMemo(() => {
if (!labels?.length || nPoints === 0) return [] as (string | number)[];
const start = Math.max(0, labels.length - nPoints);
return labels.slice(start);
}, [labels, nPoints]);
const yDomain = useMemo(() => {
const min = valueMin;
if (valueMax != null) return { min, max: valueMax };
let max = min;
for (const v of dataSlice) {
const n = Number(v);
if (Number.isFinite(n) && n > max) max = n;
}
if (max <= min) max = min + 1;
return { min, max: max * 1.1 };
}, [dataSlice, valueMin, valueMax]);
const project = useCallback(
(v: number) => {
const { min, max } = yDomain;
const span = max - min;
if (span <= 0) return paddingTop + drawHeight;
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
const ratio = (clipped - min) / span;
return Math.round(paddingTop + (drawHeight - ratio * drawHeight));
},
[yDomain, paddingTop, drawHeight],
);
const pointsArr = useMemo<[number, number][]>(() => {
if (nPoints === 0) return [];
const w = drawWidth;
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
return dataSlice.map((v, i) => {
const x = Math.round(paddingLeft + i * dx);
return [x, project(v)];
});
}, [dataSlice, nPoints, drawWidth, paddingLeft, project]);
const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]);
const areaPath = useMemo(() => {
if (pointsArr.length === 0) return '';
const first = pointsArr[0];
const last = pointsArr[pointsArr.length - 1];
const baseY = paddingTop + drawHeight;
const line = pointsStr.replace(/ /g, ' L ');
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
}, [pointsArr, pointsStr, paddingTop, drawHeight]);
const gridLines = useMemo(() => {
if (!showGrid) return [];
const h = drawHeight;
const w = drawWidth;
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
const y = Math.round(paddingTop + h * r);
return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y };
});
}, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]);
const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1];
const yTicks = useMemo(() => {
if (!showAxes) return [];
const { min, max } = yDomain;
const out: { y: number; label: string }[] = [];
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
for (let p = min; p <= max; p += yTickStep) {
out.push({ y: project(p), label: yFormatter(p) });
}
return out;
}
const ticks = 5;
for (let i = 0; i < ticks; i++) {
const v = min + ((max - min) * i) / (ticks - 1);
out.push({ y: project(v), label: yFormatter(v) });
}
return out;
}, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]);
const xTicks = useMemo(() => {
if (!showAxes) return [];
if (nPoints === 0) return [];
const m = Math.max(2, tickCountX);
const w = drawWidth;
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
const out: { x: number; label: string }[] = [];
for (let i = 0; i < m; i++) {
const idx = Math.round((i * (nPoints - 1)) / (m - 1));
const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx);
const x = Math.round(paddingLeft + idx * dx);
out.push({ x, label });
}
return out;
}, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]);
const onMouseMove = useCallback(
(evt: MouseEvent<SVGSVGElement>) => {
if (!showTooltip || pointsArr.length === 0) return;
const rect = evt.currentTarget.getBoundingClientRect();
const px = evt.clientX - rect.left;
const x = (px / rect.width) * effectiveVbWidth;
const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0;
const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1))));
setHoverIdx(idx);
},
[showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft],
);
const onMouseLeave = useCallback(() => setHoverIdx(-1), []);
const hoverText = useMemo(() => {
const idx = hoverIdx;
if (idx < 0 || idx >= dataSlice.length) return '';
const raw = Number(dataSlice[idx] || 0);
const fmt = tooltipFormatter || yFormatter;
const val = fmt(Number.isFinite(raw) ? raw : 0);
const lab = labelsSlice[idx] != null ? labelsSlice[idx] : '';
return `${val}${lab ? ' • ' + lab : ''}`;
}, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]);
const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14);
const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null;
const tooltipX = hoverPoint
? Math.max(
paddingLeft + 2,
Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2),
)
: 0;
return (
<svg
ref={svgRef}
width="100%"
height={height}
viewBox={`0 0 ${effectiveVbWidth} ${height}`}
preserveAspectRatio="none"
className="sparkline-svg"
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity={Math.min(1, fillOpacity * 1.8)} />
<stop offset="50%" stopColor={stroke} stopOpacity={fillOpacity * 0.7} />
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
</linearGradient>
<filter id={shadowId} x="-10%" y="-50%" width="120%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2.4" />
<feOffset dx="0" dy="2" result="offsetBlur" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.45" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<radialGradient id={glowId}>
<stop offset="0%" stopColor={stroke} stopOpacity="0.55" />
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
</radialGradient>
</defs>
{showGrid && (
<g>
{gridLines.map((g, i) => (
<line
key={i}
x1={g.x1}
y1={g.y1}
x2={g.x2}
y2={g.y2}
stroke={gridColor}
strokeWidth={1}
strokeDasharray="3 5"
className="cpu-grid-line"
/>
))}
</g>
)}
{showAxes && (
<g>
{yTicks.map((tk, i) => (
<text
key={`y${i}`}
className="cpu-grid-y-text"
x={Math.max(0, paddingLeft - 6)}
y={tk.y + 4}
textAnchor="end"
fontSize={10.5}
>
{tk.label}
</text>
))}
{xTicks.map((tk, i) => (
<text
key={`x${i}`}
className="cpu-grid-x-text"
x={tk.x}
y={paddingTop + drawHeight + 14}
textAnchor="middle"
fontSize={10.5}
>
{tk.label}
</text>
))}
</g>
)}
{areaPath && <path d={areaPath} fill={`url(#${gradId})`} stroke="none" />}
<polyline
points={pointsStr}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
filter={`url(#${shadowId})`}
/>
{showMarker && lastPoint && (
<>
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius * 3} fill={`url(#${glowId})`}>
<animate attributeName="r" values={`${markerRadius * 2.4};${markerRadius * 3.4};${markerRadius * 2.4}`} dur="2.6s" repeatCount="indefinite" />
</circle>
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius + 1.5} fill={stroke} fillOpacity={0.25} />
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius} fill={stroke} stroke="#fff" strokeWidth={1.5} />
</>
)}
{showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && (
<g>
<line
className="cpu-grid-h-line"
x1={pointsArr[hoverIdx][0]}
x2={pointsArr[hoverIdx][0]}
y1={paddingTop}
y2={paddingTop + drawHeight}
stroke={stroke}
strokeOpacity={0.45}
strokeWidth={1}
strokeDasharray="3 4"
/>
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={5} fill={stroke} fillOpacity={0.25} />
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={3.5} fill={stroke} stroke="#fff" strokeWidth={1.5} />
<rect
x={tooltipX}
y={paddingTop + 2}
width={tooltipPillWidth}
height={18}
rx={9}
ry={9}
className="cpu-tooltip-pill"
fill={stroke}
fillOpacity={0.92}
/>
<text
className="cpu-tooltip-text"
x={tooltipX + tooltipPillWidth / 2}
y={paddingTop + 14}
textAnchor="middle"
fontSize={11}
fontWeight={600}
fill="#fff"
>
{hoverText}
</text>
</g>
)}
</svg>
);
}
-297
View File
@@ -1,297 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
const props = defineProps({
data: { type: Array, required: true },
labels: { type: Array, default: () => [] },
vbWidth: { type: Number, default: 320 },
height: { type: Number, default: 80 },
stroke: { type: String, default: '#008771' },
strokeWidth: { type: Number, default: 2 },
maxPoints: { type: Number, default: 120 },
showGrid: { type: Boolean, default: true },
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
fillOpacity: { type: Number, default: 0.15 },
showMarker: { type: Boolean, default: true },
markerRadius: { type: Number, default: 2.8 },
showAxes: { type: Boolean, default: false },
yTickStep: { type: Number, default: 25 },
tickCountX: { type: Number, default: 4 },
paddingLeft: { type: Number, default: 32 },
paddingRight: { type: Number, default: 6 },
paddingTop: { type: Number, default: 6 },
paddingBottom: { type: Number, default: 20 },
showTooltip: { type: Boolean, default: false },
// Value-range customization. When valueMax is null the chart auto-scales
// to the running max of the data (useful for unbounded series like
// network throughput or online clients). Defaults preserve the legacy
// 0..100 percent behavior so existing callers don't need to change.
valueMin: { type: Number, default: 0 },
valueMax: { type: [Number, null], default: 100 },
// Y-axis tick formatter. Receives the raw value, returns the label.
// tooltipFormatter formats the hover-readout; falls back to yFormatter.
yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
tooltipFormatter: { type: Function, default: null },
});
const hoverIdx = ref(-1);
// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
// 1:1 with rendered pixels otherwise `preserveAspectRatio="none"`
// stretches the X axis and squashes axis text horizontally on narrow
// containers (mobile). Falls back to the prop until the first measure.
const svgRef = ref(null);
const measuredWidth = ref(0);
const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
let resizeObserver = null;
function measure() {
const el = svgRef.value;
if (!el) return;
const w = el.getBoundingClientRect?.().width || 0;
if (w > 0) measuredWidth.value = Math.round(w);
}
onMounted(() => {
measure();
if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(svgRef.value);
} else {
window.addEventListener('resize', measure);
}
});
onBeforeUnmount(() => {
if (resizeObserver) resizeObserver.disconnect();
else window.removeEventListener('resize', measure);
});
const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
const dataSlice = computed(() => {
const n = nPoints.value;
if (n === 0) return [];
return props.data.slice(props.data.length - n);
});
const labelsSlice = computed(() => {
const n = nPoints.value;
if (!props.labels?.length || n === 0) return [];
const start = Math.max(0, props.labels.length - n);
return props.labels.slice(start);
});
// Resolved domain. When valueMax is null we auto-scale; pad the upper
// bound by 10% so the line never touches the top edge looks more
// natural and gives the axis a sane ceiling. Floor the dynamic range
// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
const yDomain = computed(() => {
const min = props.valueMin;
if (props.valueMax != null) return { min, max: props.valueMax };
let max = min;
for (const v of dataSlice.value) {
const n = Number(v);
if (Number.isFinite(n) && n > max) max = n;
}
if (max <= min) max = min + 1;
return { min, max: max * 1.1 };
});
function project(v) {
const { min, max } = yDomain.value;
const span = max - min;
if (span <= 0) return props.paddingTop + drawHeight.value;
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
const ratio = (clipped - min) / span;
return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
}
const pointsArr = computed(() => {
const n = nPoints.value;
if (n === 0) return [];
const slice = dataSlice.value;
const w = drawWidth.value;
const dx = n > 1 ? w / (n - 1) : 0;
return slice.map((v, i) => {
const x = Math.round(props.paddingLeft + i * dx);
return [x, project(v)];
});
});
const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
const areaPath = computed(() => {
if (pointsArr.value.length === 0) return '';
const first = pointsArr.value[0];
const last = pointsArr.value[pointsArr.value.length - 1];
const baseY = props.paddingTop + drawHeight.value;
const line = pointsStr.value.replace(/ /g, ' L ');
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
});
const gridLines = computed(() => {
if (!props.showGrid) return [];
const h = drawHeight.value;
const w = drawWidth.value;
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
const y = Math.round(props.paddingTop + h * r);
return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
});
});
const lastPoint = computed(() => {
if (pointsArr.value.length === 0) return null;
return pointsArr.value[pointsArr.value.length - 1];
});
// Y-axis tick rendering. We pick a small number of evenly spaced values
// inside the resolved domain and run them through yFormatter that's
// what makes "MB/s" / "clients" / "%" all render correctly without the
// caller having to subclass the component.
const yTicks = computed(() => {
if (!props.showAxes) return [];
const { min, max } = yDomain.value;
const out = [];
// For percent-style domains keep the legacy fixed step; otherwise
// default to 4 evenly spaced ticks (5 lines including the bottom).
if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
for (let p = min; p <= max; p += props.yTickStep) {
const y = project(p);
out.push({ y, label: props.yFormatter(p) });
}
return out;
}
const ticks = 5;
for (let i = 0; i < ticks; i++) {
const v = min + ((max - min) * i) / (ticks - 1);
out.push({ y: project(v), label: props.yFormatter(v) });
}
return out;
});
const xTicks = computed(() => {
if (!props.showAxes) return [];
const labels = labelsSlice.value;
const n = nPoints.value;
if (n === 0) return [];
const m = Math.max(2, props.tickCountX);
const w = drawWidth.value;
const dx = n > 1 ? w / (n - 1) : 0;
const out = [];
for (let i = 0; i < m; i++) {
const idx = Math.round((i * (n - 1)) / (m - 1));
const label = labels[idx] != null ? String(labels[idx]) : String(idx);
const x = Math.round(props.paddingLeft + idx * dx);
out.push({ x, label });
}
return out;
});
function onMouseMove(evt) {
if (!props.showTooltip || pointsArr.value.length === 0) return;
const rect = evt.currentTarget.getBoundingClientRect();
const px = evt.clientX - rect.left;
const x = (px / rect.width) * effectiveVbWidth.value;
const n = nPoints.value;
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
hoverIdx.value = idx;
}
function onMouseLeave() {
hoverIdx.value = -1;
}
function fmtHoverText() {
const idx = hoverIdx.value;
if (idx < 0 || idx >= dataSlice.value.length) return '';
const raw = Number(dataSlice.value[idx] || 0);
const fmt = props.tooltipFormatter || props.yFormatter;
const val = fmt(Number.isFinite(raw) ? raw : 0);
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
return `${val}${lab ? ' • ' + lab : ''}`;
}
// Stable per-instance gradient id so multiple sparklines on a page
// don't clobber each other's <defs id="spkGrad">.
const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
</script>
<template>
<svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<defs>
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
<stop offset="100%" :stop-color="stroke" stop-opacity="0" />
</linearGradient>
</defs>
<g v-if="showGrid">
<line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
stroke-width="1" class="cpu-grid-line" />
</g>
<g v-if="showAxes">
<text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
:y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
<text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
text-anchor="middle" font-size="10">{{ t.label }}</text>
</g>
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
<polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
stroke-linejoin="round" />
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
<line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
:y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
<text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
font-size="11">{{ fmtHoverText() }}</text>
</g>
</svg>
</template>
<style scoped>
.sparkline-svg {
display: block;
width: 100%;
}
</style>
<!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
reliably hash-attribute SVG descendants, so the dark-mode overrides
have to live in a non-scoped block to actually take effect. The
numbers are also small, so the dark-theme fills run at ~85% opacity
for legibility (the previous 55% was washed out on navy backgrounds). -->
<style>
.sparkline-svg .cpu-grid-y-text,
.sparkline-svg .cpu-grid-x-text {
fill: rgba(0, 0, 0, 0.65);
}
.sparkline-svg .cpu-grid-text {
fill: rgba(0, 0, 0, 0.88);
}
body.dark .sparkline-svg .cpu-grid-y-text,
body.dark .sparkline-svg .cpu-grid-x-text {
fill: rgba(255, 255, 255, 0.85);
}
body.dark .sparkline-svg .cpu-grid-text {
fill: rgba(255, 255, 255, 0.95);
}
body.dark .sparkline-svg .cpu-grid-line {
stroke: rgba(255, 255, 255, 0.12);
}
body.dark .sparkline-svg .cpu-grid-h-line {
stroke: rgba(255, 255, 255, 0.35);
}
</style>
-311
View File
@@ -1,311 +0,0 @@
<script>
// Use defineComponent so we can keep the parent + child components in
// the same file with the provide() <-> inject relationship intact.
import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
import { DragOutlined } from '@ant-design/icons-vue';
const ROW_CLASS = 'sortable-row';
// Sortable a-table drag-to-reorder rows using Pointer Events.
//
// Why a custom component:
// - Old impl set draggable: true on every row, which broke text selection
// in cells and let HTML5 start drags from anywhere on the row. This
// version only initiates drag from an explicit handle, via Pointer
// Events (one API for mouse + touch + pen).
// - During drag, data-source is reordered live; the source row visually
// slides into the target slot. The live reorder IS the visual feedback.
// - On commit, emits onsort(sourceIndex, targetIndex) same signature as
// before so existing call sites stay unchanged.
// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
// one; Escape cancels an in-flight drag.
export const TableSortableTrigger = defineComponent({
name: 'TableSortableTrigger',
props: {
itemIndex: { type: Number, required: true },
},
setup(props) {
const sortable = inject('sortable', null);
const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
function onPointerDown(e) {
sortable?.startDrag?.(e, props.itemIndex);
}
function onKeyDown(e) {
const move = sortable?.moveByKeyboard;
if (!move) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
move(-1, props.itemIndex);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
move(+1, props.itemIndex);
}
}
return () => h(DragOutlined, {
class: 'sortable-icon',
role: 'button',
tabindex: 0,
'aria-label': ariaLabel.value,
onPointerdown: onPointerDown,
onKeydown: onKeyDown,
});
},
});
export default defineComponent({
name: 'TableSortable',
inheritAttrs: false,
props: {
dataSource: { type: Array, default: () => [] },
customRow: { type: Function, default: null },
rowKey: { type: [String, Function], default: null },
locale: {
type: Object,
default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
},
},
emits: ['onsort'],
setup(props, { emit, slots, attrs, expose }) {
// null when idle; while dragging:
// { sourceIndex, targetIndex, pointerId, sourceKey }
const drag = ref(null);
const rootRef = ref(null);
const isDragging = computed(() => drag.value !== null);
// Resolve the row key for a record. Used to identify the source row
// even after data-source is reordered live during drag.
function keyOf(record, fallback) {
const rk = props.rowKey;
if (typeof rk === 'function') return rk(record);
if (typeof rk === 'string') return record?.[rk];
return fallback;
}
function attachListeners() {
document.addEventListener('pointermove', onPointerMove, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('pointercancel', cancelDrag, true);
document.addEventListener('keydown', cancelDrag, true);
}
function detachListeners() {
document.removeEventListener('pointermove', onPointerMove, true);
document.removeEventListener('pointerup', onPointerUp, true);
document.removeEventListener('pointercancel', cancelDrag, true);
document.removeEventListener('keydown', cancelDrag, true);
}
function startDrag(e, sourceIndex) {
// Primary button only (mouse left / first touch).
if (e.button != null && e.button !== 0) return;
e.preventDefault();
const record = props.dataSource?.[sourceIndex];
drag.value = {
sourceIndex,
targetIndex: sourceIndex,
pointerId: e.pointerId,
sourceKey: keyOf(record, sourceIndex),
};
// Capture the pointer so move/up keep firing even if the cursor
// leaves the icon. Try/catch some older browsers throw on capture.
if (e.target?.setPointerCapture && e.pointerId != null) {
try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
}
attachListeners();
}
function onPointerMove(e) {
const d = drag.value;
if (!d) return;
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
const root = rootRef.value;
if (!root) return;
const rows = root.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 = d.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 !== d.targetIndex) {
drag.value = { ...d, targetIndex: target };
}
}
function onPointerUp(e) {
const d = drag.value;
if (!d) return;
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
detachListeners();
const captured = d;
drag.value = null;
if (captured.sourceIndex !== captured.targetIndex) {
emit('onsort', captured.sourceIndex, captured.targetIndex);
}
}
function cancelDrag(e) {
// Triggered by pointercancel and keydown. For keydown only act on
// Escape; otherwise let the event propagate.
if (e?.type === 'keydown' && e.key !== 'Escape') return;
detachListeners();
drag.value = null;
}
function moveByKeyboard(direction, sourceIndex) {
const target = sourceIndex + direction;
if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
emit('onsort', sourceIndex, target);
}
function customRowRender(record, index) {
const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
const d = drag.value;
const isSource = d && keyOf(record, index) === d.sourceKey;
// Vue 3 customRow shape: a flat object of attrs/listeners/class
// no nested props/on like Vue 2.
return {
...parent,
class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
};
}
// Render-data: dataSource with the source row spliced into targetIndex.
// When idle the original list is returned unchanged so a-table can
// diff against a stable reference.
const records = computed(() => {
const d = drag.value;
const src = props.dataSource ?? [];
if (!d || d.sourceIndex === d.targetIndex) return src;
const list = src.slice();
const [item] = list.splice(d.sourceIndex, 1);
list.splice(d.targetIndex, 0, item);
return list;
});
expose({ startDrag, moveByKeyboard });
return {
rootRef, drag, isDragging, records, slots, attrs,
startDrag, moveByKeyboard, customRowRender,
};
},
// provide() needs to live at the options level so child components in
// the rendered subtree resolve the same instance methods.
provide() {
return {
sortable: {
startDrag: (...a) => this.startDrag(...a),
moveByKeyboard: (...a) => this.moveByKeyboard(...a),
},
};
},
beforeUnmount() {
document.removeEventListener('pointermove', this.onPointerMove, true);
document.removeEventListener('pointerup', this.onPointerUp, true);
document.removeEventListener('pointercancel', this.cancelDrag, true);
document.removeEventListener('keydown', this.cancelDrag, true);
},
render() {
// Forward every passed slot to a-table by reusing the slot fn
// directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
const tableSlots = {};
for (const name of Object.keys(this.slots)) {
tableSlots[name] = this.slots[name];
}
// Resolved at runtime so the user's app.use(Antd) registration wins;
// avoids importing Table directly here.
const ATable = resolveComponent('a-table');
return h(
'div',
{ ref: 'rootRef' },
[h(
ATable,
{
...this.attrs,
'data-source': this.records,
'row-key': this.rowKey,
customRow: this.customRowRender,
locale: this.locale,
class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
},
tableSlots,
)],
);
},
});
</script>
<style>
.sortable-icon {
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;
}
.light .sortable-icon {
color: rgba(0, 0, 0, 0.45);
}
.light .sortable-icon:hover {
color: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.05);
}
.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,
.sortable-table-dragging .sortable-source-row .outbound-index {
opacity: 0.45;
}
.sortable-table-dragging .sortable-row>td {
transition: background-color 0.18s ease;
}
.sortable-table-dragging,
.sortable-table-dragging * {
user-select: none;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
import { Button, Input, Modal, message } from 'antd';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
import { ClipboardManager, FileManager } from '@/utils';
interface TextModalProps {
open: boolean;
onClose: () => void;
title: string;
content: string;
fileName?: string;
}
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
const [messageApi, messageContextHolder] = message.useMessage();
async function copy() {
const ok = await ClipboardManager.copyText(content || '');
if (ok) {
messageApi.success('Copied');
onClose();
}
}
function download() {
if (!fileName) return;
FileManager.downloadTextFile(content, fileName);
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
onCancel={onClose}
destroyOnHidden
footer={(
<>
{fileName && (
<Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
)}
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>Copy</Button>
</>
)}
>
<Input.TextArea
value={content}
readOnly
autoSize={{ minRows: 10, maxRows: 20 }}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
overflowY: 'auto',
}}
/>
</Modal>
</>
);
}
-66
View File
@@ -1,66 +0,0 @@
<script setup>
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { ClipboardManager, FileManager } from '@/utils';
// Read-only text modal used to surface multi-line export blobs
// (subscription URLs, raw inbound JSON, generated share links) the
// way the legacy txtModal did.
defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
content: { type: String, default: '' },
// When set, surfaces a download button that writes `content` to a
// text file with this name.
fileName: { type: String, default: '' },
});
const emit = defineEmits(['update:open']);
function close() {
emit('update:open', false);
}
async function copy(value) {
const ok = await ClipboardManager.copyText(value || '');
if (ok) {
message.success('Copied');
close();
}
}
function download(content, name) {
if (!name) return;
FileManager.downloadTextFile(content, name);
}
</script>
<template>
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
<a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
<template #footer>
<a-button v-if="fileName" @click="download(content, fileName)">
<template #icon>
<DownloadOutlined />
</template>
{{ fileName }}
</a-button>
<a-button type="primary" @click="copy(content)">
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
</template>
</a-modal>
</template>
<style scoped>
.text-modal-content {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
overflow-y: auto;
}
</style>
-49
View File
@@ -1,49 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
const { t } = useI18n();
const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
function onDarkChange() {
pauseAnimationsUntilLeave('change-theme');
toggleTheme();
}
function onUltraClick() {
pauseAnimationsUntilLeave('change-theme-ultra');
toggleUltra();
}
</script>
<template>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
<a-sub-menu>
<template #title>
<span>
<component :is="BulbIcon" />
<span class="theme-label">{{ t('menu.theme') }}</span>
</span>
</template>
<a-menu-item id="change-theme" class="ant-menu-theme-switch">
<span>{{ t('menu.dark') }}</span>
<a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
</a-menu-item>
<a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
<span>{{ t('menu.ultraDark') }}</span>
<a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
</a-menu-item>
</a-sub-menu>
</a-menu>
</template>
<style scoped>
.theme-label {
margin-left: 8px;
}
</style>
@@ -1,25 +0,0 @@
<script setup>
import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
function onDarkChange() {
pauseAnimationsUntilLeave('change-theme');
toggleTheme();
}
function onUltraClick() {
toggleUltra();
}
</script>
<template>
<a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
<a-space direction="horizontal" size="small">
<a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
<span>Dark</span>
</a-space>
<a-space v-if="theme.isDark" direction="horizontal" size="small">
<a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
<span>Ultra dark</span>
</a-space>
</a-space>
</template>
-45
View File
@@ -1,45 +0,0 @@
// Module-scoped reactive ref for the panel's "Calendar Type" setting.
// Loaded from /panel/setting/defaultSettings on first use, so any
// component (modals, inbound forms, future pages) can read the same
// value without prop-drilling and without re-fetching.
//
// useInbounds (which already reads defaultSettings for its own state)
// calls setDatepicker() after its fetch so we don't issue a second
// HTTP round-trip on the inbounds page.
import { readonly, ref } from 'vue';
import { HttpUtil } from '@/utils';
const datepicker = ref('gregorian');
let fetched = false;
let pending = null;
async function loadOnce() {
if (fetched) return;
if (pending) {
await pending;
return;
}
pending = (async () => {
try {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (msg?.success) {
datepicker.value = msg.obj?.datepicker || 'gregorian';
}
} finally {
fetched = true;
pending = null;
}
})();
await pending;
}
export function setDatepicker(value) {
fetched = true;
datepicker.value = value || 'gregorian';
}
export function useDatepicker() {
loadOnce();
return { datepicker: readonly(datepicker) };
}
-26
View File
@@ -1,26 +0,0 @@
import { ref, onBeforeUnmount, onMounted } from 'vue';
const MOBILE_BREAKPOINT_PX = 768;
// Vue 3 replacement for the legacy MediaQueryMixin. Returns a reactive
// `isMobile` ref that updates on window resize. Use inside <script setup>:
//
// const { isMobile } = useMediaQuery();
export function useMediaQuery(breakpoint = MOBILE_BREAKPOINT_PX) {
const compute = () => window.innerWidth <= breakpoint;
const isMobile = ref(compute());
const onResize = () => {
isMobile.value = compute();
};
onMounted(() => {
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
});
return { isMobile };
}
-42
View File
@@ -1,42 +0,0 @@
// Lightweight composable that fetches the node list once on mount and
// exposes id→name + id→online lookups. Used by the Inbounds page so it
// can render a Node selector and a Node column without pulling the
// full pages/nodes/useNodes.js (which polls and owns CRUD state).
import { onMounted, ref, computed } from 'vue';
import { HttpUtil } from '@/utils';
export function useNodeList() {
const nodes = ref([]);
const fetched = ref(false);
async function refresh() {
const msg = await HttpUtil.get('/panel/api/nodes/list');
if (msg?.success) {
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
}
fetched.value = true;
}
// Indexed by id for O(1) UI lookups (Node column on N-row tables).
const byId = computed(() => {
const m = new Map();
for (const n of nodes.value) m.set(n.id, n);
return m;
});
function nameFor(id) {
if (id == null) return null;
return byId.value.get(id)?.name || null;
}
function isOnline(id) {
if (id == null) return true;
const n = byId.value.get(id);
return n != null && n.enable && n.status === 'online';
}
onMounted(refresh);
return { nodes, fetched, refresh, byId, nameFor, isOnline };
}
-43
View File
@@ -1,43 +0,0 @@
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { HttpUtil } from '@/utils';
import { Status } from '@/models/status.js';
const POLL_INTERVAL_MS = 2000;
// Polls /panel/api/server/status and exposes a reactive Status object
// + a `fetched` flag so consumers can show a spinner before the first
// successful fetch.
//
// WebSocket integration is intentionally deferred to a later sub-phase.
// Polling at 2s is the same fallback the legacy panel falls back to
// when its websocket link drops, so we're shipping the proven path
// first and adding the websocket on top later.
export function useStatus() {
const status = shallowRef(new Status());
const fetched = ref(false);
let timer = null;
async function refresh() {
try {
const msg = await HttpUtil.get('/panel/api/server/status');
if (msg?.success) {
status.value = new Status(msg.obj);
if (!fetched.value) fetched.value = true;
}
} catch (e) {
console.error('Failed to get status:', e);
}
}
onMounted(() => {
refresh();
timer = window.setInterval(refresh, POLL_INTERVAL_MS);
});
onBeforeUnmount(() => {
if (timer != null) window.clearInterval(timer);
});
return { status, fetched, refresh };
}
-128
View File
@@ -1,128 +0,0 @@
import { reactive, computed, watchEffect } from 'vue';
import { theme as antdTheme } from 'ant-design-vue';
// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
// from any component to read/toggle. Boot side-effects (apply current
// theme to <body>/<html>) run once at module load so the page is in the
// right theme before Vue mounts.
const STORAGE_DARK = 'dark-mode';
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
function readBool(key, fallback) {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return raw === 'true';
}
const isDark = readBool(STORAGE_DARK, true);
const isUltra = readBool(STORAGE_ULTRA, false);
export const theme = reactive({
isDark,
isUltra,
});
export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
// blue primary. Dark uses a navy palette across page/cards/modals so
// the sidebar blends with the rest of the surface; ultra-dark stays
// neutral black on top of darkAlgorithm.
const DARK_TOKENS = {
colorBgBase: '#0a1426',
colorBgLayout: '#0a1426',
colorBgContainer: '#142340',
colorBgElevated: '#1a2c4d',
};
const ULTRA_DARK_TOKENS = {
colorBgBase: '#000',
colorBgLayout: '#000',
colorBgContainer: '#0a0a0a',
colorBgElevated: '#141414',
};
// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
// index.js). Override at the component-token level so the sider blends
// with darkAlgorithm's neutral surfaces.
// Dark theme uses a refined navy for the sidebar — distinct from the
// neutral ultra-dark and warmer than AD-Vue's stock #001529.
const DARK_LAYOUT_TOKENS = {
colorBgHeader: '#0d1d33',
colorBgTrigger: '#15294a',
colorBgBody: '#000',
};
const ULTRA_DARK_LAYOUT_TOKENS = {
colorBgHeader: '#0a0a0a',
colorBgTrigger: '#141414',
colorBgBody: '#000',
};
const DARK_MENU_TOKENS = {
colorItemBg: '#0d1d33',
colorSubItemBg: '#08142a',
menuSubMenuBg: '#0d1d33',
};
const ULTRA_DARK_MENU_TOKENS = {
colorItemBg: '#0a0a0a',
colorSubItemBg: '#000',
menuSubMenuBg: '#0a0a0a',
};
export const antdThemeConfig = computed(() => {
if (!theme.isDark) {
return { algorithm: antdTheme.defaultAlgorithm };
}
return {
algorithm: antdTheme.darkAlgorithm,
token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
},
};
});
export function toggleTheme() {
theme.isDark = !theme.isDark;
}
export function toggleUltra() {
theme.isUltra = !theme.isUltra;
}
// Briefly disable theme transition animations while a toggle is in
// flight, then re-enable on mouseleave. Mirrors the legacy panel's
// behavior of preventing flicker when hovering the theme menu.
export function pauseAnimationsUntilLeave(elementId) {
document.documentElement.setAttribute('data-theme-animations', 'off');
const el = document.getElementById(elementId);
if (!el) return;
const restore = () => {
document.documentElement.removeAttribute('data-theme-animations');
el.removeEventListener('mouseleave', restore);
el.removeEventListener('touchend', restore);
};
el.addEventListener('mouseleave', restore);
el.addEventListener('touchend', restore);
}
// Apply theme to DOM and persist whenever it changes.
watchEffect(() => {
document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
localStorage.setItem(STORAGE_DARK, String(theme.isDark));
if (theme.isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
// Keep the global #message container's class in sync so AD-Vue toasts
// pick up the right styling.
const msg = document.getElementById('message');
if (msg) msg.className = theme.isDark ? 'dark' : 'light';
});
-48
View File
@@ -1,48 +0,0 @@
import { onBeforeUnmount, onMounted } from 'vue';
import { WebSocketClient } from '@/api/websocket.js';
// One client per browser tab (= per multi-page entry). WebSocketClient is
// idempotent: repeated connect() calls while the socket is already open
// are no-ops, so multiple components on the same page can share a single
// underlying connection without each spawning their own.
let sharedClient = null;
function getSharedClient() {
if (sharedClient) return sharedClient;
const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || '';
sharedClient = new WebSocketClient(basePath);
return sharedClient;
}
// useWebSocket lets a Vue component subscribe to live server-pushed
// events. Pass a map of { eventName: handler } and the composable wires
// connect()/disconnect() into the component lifecycle and unsubscribes
// every handler on unmount so a stale closure can't fire after the
// page has moved on.
//
// Example:
// useWebSocket({
// traffic: (payload) => applyTrafficEvent(payload),
// client_stats: (payload) => applyClientStatsEvent(payload),
// invalidate: ({ type }) => { if (type === 'inbounds') refresh(); },
// });
//
// Built-in lifecycle events ('connected' / 'disconnected' / 'error')
// can be subscribed to alongside server-emitted types.
export function useWebSocket(handlers) {
const client = getSharedClient();
const entries = Object.entries(handlers || {});
onMounted(() => {
for (const [event, fn] of entries) client.on(event, fn);
client.connect();
});
onBeforeUnmount(() => {
for (const [event, fn] of entries) client.off(event, fn);
// Don't disconnect — another mounted component on the same page may
// still be subscribed. The client closes naturally on page unload.
});
return { client };
}
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<ApiDocsPage />
</ThemeProvider>,
);
}
});
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import ClientsPage from '@/pages/clients/ClientsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<ClientsPage />
</ThemeProvider>,
);
}
});
-17
View File
@@ -1,17 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import InboundsPage from '@/pages/inbounds/InboundsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<InboundsPage />
</ThemeProvider>,
);
}
});
-19
View File
@@ -1,19 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import IndexPage from '@/pages/index/IndexPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import IndexPage from '@/pages/index/IndexPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<IndexPage />
</ThemeProvider>,
);
}
});
-21
View File
@@ -1,21 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
// Toasts attach to a #message div the page provides — keeps theme
// styling in sync with the rest of the panel.
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import LoginPage from '@/pages/login/LoginPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<LoginPage />
</ThemeProvider>,
);
}
});
-17
View File
@@ -1,17 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import NodesPage from '@/pages/nodes/NodesPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import NodesPage from '@/pages/nodes/NodesPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<NodesPage />
</ThemeProvider>,
);
}
});
-19
View File
@@ -1,19 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import SettingsPage from '@/pages/settings/SettingsPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import SettingsPage from '@/pages/settings/SettingsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<SettingsPage />
</ThemeProvider>,
);
}
});
-18
View File
@@ -1,18 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
// The sub page is served by the subscription HTTP server (sub/sub.go)
// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
// with the parsed traffic/quota/expiry view-model and the rendered
// share links — the SPA reads those at mount.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import SubPage from '@/pages/sub/SubPage.vue';
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(SubPage).use(Antd).use(i18n).mount('#app');
+23
View File
@@ -0,0 +1,23 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import SubPage from '@/pages/sub/SubPage';
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<SubPage />
</ThemeProvider>,
);
}
});
-17
View File
@@ -1,17 +0,0 @@
import { createApp } from 'vue';
import Antd, { message } from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import XrayPage from '@/pages/xray/XrayPage.vue';
setupAxios();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
+28
View File
@@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import XrayPage from '@/pages/xray/XrayPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<XrayPage />
</ThemeProvider>,
);
}
});
+65
View File
@@ -0,0 +1,65 @@
/// <reference types="vite/client" />
interface SubPageData {
sId?: string;
enabled?: boolean;
download?: string;
upload?: string;
total?: string;
used?: string;
remained?: string;
totalByte?: string | number;
expire?: string | number;
lastOnline?: string | number;
subUrl?: string;
subJsonUrl?: string;
subClashUrl?: string;
subTitle?: string;
links?: string[];
datepicker?: 'gregorian' | 'jalalian';
downloadByte?: string | number;
uploadByte?: string | number;
usedByte?: string | number;
}
interface Window {
X_UI_BASE_PATH?: string;
X_UI_CUR_VER?: string;
__SUB_PAGE_DATA__?: SubPageData;
}
declare module 'persian-calendar-suite' {
import type { ComponentType, ReactNode } from 'react';
type DateInput = string | number | null;
type OutputFormat = 'iso' | 'shamsi' | 'gregorian' | 'hijri' | 'timestamp';
interface PersianDateTimePickerProps {
value?: DateInput;
onChange?: (value: number | string | null) => void;
defaultValue?: string | number | 'now' | null;
showTime?: boolean;
minuteStep?: number;
outputFormat?: OutputFormat;
showFooter?: boolean;
theme?: Record<string, unknown>;
disabledHours?: number[];
minDate?: string | Date | null;
maxDate?: string | Date | null;
enabledDates?: string[] | null;
disabledDates?: string[] | null;
disabledWeekDays?: number[];
persianNumbers?: boolean;
rtlCalendar?: boolean;
placeholder?: string;
disabled?: boolean;
className?: string;
children?: ReactNode;
}
export const PersianDateTimePicker: ComponentType<PersianDateTimePickerProps>;
export const PersianCalendar: ComponentType<Record<string, unknown>>;
export const PersianDateRangePicker: ComponentType<Record<string, unknown>>;
export const PersianTimePicker: ComponentType<Record<string, unknown>>;
export const PersianTimeline: ComponentType<Record<string, unknown>>;
}
+69
View File
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
import { AllSetting } from '@/models/setting';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
}
export function useAllSetting() {
const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
const [fetched, setFetched] = useState(false);
const [spinning, setSpinning] = useState(false);
const fetchedRef = useRef(false);
const applyServerState = useCallback((obj: unknown) => {
setAllSetting(new AllSetting(obj));
setOldAllSetting(new AllSetting(obj));
}, []);
const fetchAll = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
if (msg?.success) {
applyServerState(msg.obj);
fetchedRef.current = true;
setFetched(true);
}
}, [applyServerState]);
const saveAll = useCallback(async () => {
setSpinning(true);
try {
const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
if (msg?.success) await fetchAll();
} finally {
setSpinning(false);
}
}, [allSetting, fetchAll]);
const updateSetting = useCallback((patch: Partial<AllSetting>) => {
setAllSetting((prev) => {
const next = new AllSetting(prev);
Object.assign(next, patch);
return next;
});
}, []);
const saveDisabled = useMemo(
() => allSetting.equals(oldAllSetting),
[allSetting, oldAllSetting],
);
useEffect(() => {
fetchAll();
}, [fetchAll]);
return {
allSetting,
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
fetchAll,
saveAll,
};
}
+391
View File
@@ -0,0 +1,391 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
export interface ClientTraffic {
up?: number;
down?: number;
total?: number;
expiryTime?: number;
enable?: boolean;
lastOnline?: number;
}
export interface ClientRecord {
email: string;
subId?: string;
uuid?: string;
password?: string;
auth?: string;
flow?: string;
totalGB?: number;
expiryTime?: number;
limitIp?: number;
tgId?: number | string;
comment?: string;
enable?: boolean;
inboundIds?: number[];
traffic?: ClientTraffic;
reverse?: { tag?: string };
createdAt?: number;
updatedAt?: number;
[key: string]: unknown;
}
export interface InboundOption {
id: number;
remark?: string;
protocol?: string;
port?: number;
tlsFlowCapable?: boolean;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
}
export interface ClientQueryParams {
page: number;
pageSize: number;
search?: string;
filter?: string;
protocol?: string;
inbound?: number;
sort?: string;
order?: 'ascend' | 'descend';
}
export interface ClientsSummary {
total: number;
active: number;
online: string[];
depleted: string[];
expiring: string[];
deactive: string[];
}
interface ClientPageResponse {
items: ClientRecord[];
total: number;
filtered: number;
page: number;
pageSize: number;
summary?: ClientsSummary;
}
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
export function useClients() {
const [clients, setClients] = useState<ClientRecord[]>([]);
const [total, setTotal] = useState(0);
const [filtered, setFiltered] = useState(0);
const [summary, setSummary] = useState<ClientsSummary>({
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
});
const [inbounds, setInbounds] = useState<InboundOption[]>([]);
const [onlines, setOnlines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const [query, setQueryState] = useState<ClientQueryParams>(DEFAULT_QUERY);
// Shallow-compare against the previous query so callers can pass a fresh
// object on every render (the common React pattern) without triggering a
// re-fetch when nothing actually changed.
const setQuery = useCallback((next: ClientQueryParams) => {
setQueryState((prev) => {
if (
prev.page === next.page
&& prev.pageSize === next.pageSize
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
&& (prev.protocol ?? '') === (next.protocol ?? '')
&& (prev.inbound ?? 0) === (next.inbound ?? 0)
&& (prev.sort ?? '') === (next.sort ?? '')
&& (prev.order ?? '') === (next.order ?? '')
) return prev;
return next;
});
}, []);
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
});
const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [tgBotEnable, setTgBotEnable] = useState(false);
const [expireDiff, setExpireDiff] = useState(0);
const [trafficDiff, setTrafficDiff] = useState(0);
const [pageSize, setPageSize] = useState(0);
const clientsRef = useRef<ClientRecord[]>([]);
const queryRef = useRef<ClientQueryParams>(query);
const invalidateTimerRef = useRef<number | null>(null);
useEffect(() => { clientsRef.current = clients; }, [clients]);
useEffect(() => { queryRef.current = query; }, [query]);
const buildQS = (p: ClientQueryParams) => {
const sp = new URLSearchParams();
sp.set('page', String(p.page || 1));
sp.set('pageSize', String(p.pageSize || DEFAULT_QUERY.pageSize));
if (p.search) sp.set('search', p.search);
if (p.filter) sp.set('filter', p.filter);
if (p.protocol) sp.set('protocol', p.protocol);
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
if (p.sort) sp.set('sort', p.sort);
if (p.order) sp.set('order', p.order);
return sp.toString();
};
const refresh = useCallback(async (override?: ClientQueryParams) => {
setLoading(true);
try {
const params = override ?? queryRef.current;
const qs = buildQS(params);
const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`) as ApiMsg<ClientPageResponse>;
if (msg?.success && msg.obj) {
setClients(Array.isArray(msg.obj.items) ? msg.obj.items : []);
setTotal(msg.obj.total ?? 0);
setFiltered(msg.obj.filtered ?? 0);
if (msg.obj.summary) setSummary(msg.obj.summary);
}
setFetched(true);
} finally {
setLoading(false);
}
}, []);
// Inbound options are picker-shaped and don't depend on the clients query —
// fetch them once on mount instead of every refresh.
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/inbounds/options') as ApiMsg<InboundOption[]>;
if (cancelled) return;
if (msg?.success) setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
})();
return () => { cancelled = true; };
}, []);
const fetchSubSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
if (!msg?.success) return;
const s = msg.obj || {};
setSubSettings({
enable: !!s.subEnable,
subURI: (s.subURI as string) || '',
subJsonURI: (s.subJsonURI as string) || '',
subJsonEnable: !!s.subJsonEnable,
});
setIpLimitEnable(!!s.ipLimitEnable);
setTgBotEnable(!!s.tgBotEnable);
setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000);
setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
setPageSize((s.pageSize as number) ?? 0);
}, []);
// hydrate fetches the full client record (uuid, password, flow, ...) for a
// single email. The paged list endpoint omits these to keep the row payload
// tiny; edit / info / qr / link modals call this to get a complete record
// before opening.
const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => {
if (!email) return null;
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>;
if (!msg?.success || !msg.obj) return null;
return msg.obj;
}, []);
const create = useCallback(async (payload: unknown) => {
const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const update = useCallback(async (email: string, client: unknown) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const remove = useCallback(async (email: string, keepTraffic = false) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const url = keepTraffic
? `/panel/api/clients/del/${encoded}?keepTraffic=1`
: `/panel/api/clients/del/${encoded}`;
const msg = await HttpUtil.post(url) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const removeMany = useCallback(async (emails: string[], keepTraffic = false) => {
if (!Array.isArray(emails) || emails.length === 0) return [];
const suffix = keepTraffic ? '?keepTraffic=1' : '';
const results = await Promise.all(emails.map((email) => {
const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
}));
await refresh();
return results;
}, [refresh]);
const bulkAdjust = useCallback(async (emails: string[], addDays: number, addBytes: number) => {
if (!Array.isArray(emails) || emails.length === 0) return null;
const msg = await HttpUtil.post(
'/panel/api/clients/bulkAdjust',
{ emails, addDays, addBytes },
JSON_HEADERS,
) as ApiMsg<{ adjusted: number; skipped?: { email: string; reason: string }[] }>;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const attach = useCallback(async (email: string, inboundIds: number[]) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const detach = useCallback(async (email: string, inboundIds: number[]) => {
if (!email) return null;
const encoded = encodeURIComponent(email);
const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const resetTraffic = useCallback(async (client: ClientRecord) => {
if (!client?.email) return null;
const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
const msg = await HttpUtil.post(url) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const resetAllTraffics = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const delDepleted = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
if (!client?.email) return null;
const payload = {
email: client.email,
subId: client.subId,
id: client.uuid,
password: client.password,
auth: client.auth,
totalGB: client.totalGB || 0,
expiryTime: client.expiryTime || 0,
limitIp: client.limitIp || 0,
comment: client.comment || '',
enable: !!enable,
};
return update(client.email, payload);
}, [update]);
const applyTrafficEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[] };
if (Array.isArray(p.onlineClients)) {
setOnlines(p.onlineClients);
}
}, []);
const applyClientStatsEvent = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { clients?: ClientTraffic[] & { email?: string }[] };
if (!Array.isArray(p.clients) || p.clients.length === 0) return;
const byEmail = new Map<string, ClientTraffic>();
for (const row of p.clients as (ClientTraffic & { email?: string })[]) {
if (row && row.email) byEmail.set(row.email, row);
}
const cur = clientsRef.current || [];
let touched = false;
const next = cur.slice();
for (let i = 0; i < next.length; i++) {
const row = next[i];
const upd = byEmail.get(row?.email);
if (!upd) continue;
const merged: ClientTraffic = { ...(row.traffic || {}) };
if (typeof upd.up === 'number') merged.up = upd.up;
if (typeof upd.down === 'number') merged.down = upd.down;
if (typeof upd.total === 'number') merged.total = upd.total;
if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
next[i] = { ...row, traffic: merged };
touched = true;
}
if (touched) setClients(next);
}, []);
const applyInvalidate = useCallback((payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { type?: string };
if (p.type !== 'inbounds' && p.type !== 'clients') return;
if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
invalidateTimerRef.current = window.setTimeout(() => {
invalidateTimerRef.current = null;
refresh();
}, 200);
}, [refresh]);
useEffect(() => {
Promise.all([refresh(query), fetchSubSettings()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, fetchSubSettings]);
return {
clients,
total,
filtered,
summary,
hydrate,
query,
setQuery,
inbounds,
onlines,
loading,
fetched,
subSettings,
ipLimitEnable,
tgBotEnable,
expireDiff,
trafficDiff,
pageSize,
refresh,
create,
update,
remove,
removeMany,
bulkAdjust,
attach,
detach,
resetTraffic,
resetAllTraffics,
delDepleted,
setEnable,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
};
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { HttpUtil } from '@/utils';
type Calendar = 'gregorian' | 'jalalian';
let cachedValue: Calendar = 'gregorian';
let fetched = false;
let pending: Promise<void> | null = null;
const listeners = new Set<(value: Calendar) => void>();
function notify(value: Calendar) {
listeners.forEach((fn) => fn(value));
}
async function loadOnce(): Promise<void> {
if (fetched) return;
if (pending) {
await pending;
return;
}
pending = (async () => {
try {
const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
success?: boolean;
obj?: { datepicker?: Calendar };
};
if (msg?.success) {
cachedValue = msg.obj?.datepicker || 'gregorian';
notify(cachedValue);
}
} finally {
fetched = true;
pending = null;
}
})();
await pending;
}
export function setDatepicker(value: Calendar) {
fetched = true;
cachedValue = value || 'gregorian';
notify(cachedValue);
}
export function useDatepicker() {
const [datepicker, setLocal] = useState<Calendar>(cachedValue);
useEffect(() => {
listeners.add(setLocal);
loadOnce();
return () => {
listeners.delete(setLocal);
};
}, []);
return { datepicker };
}
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
const MOBILE_BREAKPOINT_PX = 768;
export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= breakpoint);
useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [breakpoint]);
return { isMobile };
}
+177
View File
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
export interface NodeRecord {
id: number;
name?: string;
remark?: string;
scheme?: string;
address?: string;
port?: number;
basePath?: string;
apiToken?: string;
enable?: boolean;
status?: 'online' | 'offline' | string;
latencyMs?: number;
cpuPct?: number;
memPct?: number;
xrayVersion?: string;
panelVersion?: string;
uptimeSecs?: number;
inboundCount?: number;
clientCount?: number;
onlineCount?: number;
depletedCount?: number;
lastHeartbeat?: number;
lastError?: string;
allowPrivateAddress?: boolean;
[key: string]: unknown;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
interface NodeTotals {
total: number;
online: number;
offline: number;
avgLatency: number;
inbounds: number;
clients: number;
onlineClients: number;
depleted: number;
}
export function useNodes() {
const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [loading, setLoading] = useState(false);
const [fetched, setFetched] = useState(false);
const fetchedRef = useRef(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.get('/panel/api/nodes/list') as ApiMsg<NodeRecord[]>;
if (msg?.success) {
setNodes(Array.isArray(msg.obj) ? msg.obj : []);
}
fetchedRef.current = true;
setFetched(true);
} finally {
setLoading(false);
}
}, []);
const applyNodesEvent = useCallback((payload: unknown) => {
if (Array.isArray(payload)) {
setNodes(payload as NodeRecord[]);
if (!fetchedRef.current) {
fetchedRef.current = true;
setFetched(true);
}
}
}, []);
const create = useCallback(async (payload: Partial<NodeRecord>) => {
const msg = await HttpUtil.post('/panel/api/nodes/add', payload) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const update = useCallback(async (id: number, payload: Partial<NodeRecord>) => {
const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const remove = useCallback(async (id: number) => {
const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const setEnable = useCallback(async (id: number, enable: boolean) => {
const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as ApiMsg;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const testConnection = useCallback(async (payload: Partial<NodeRecord>) => {
return await HttpUtil.post('/panel/api/nodes/test', payload) as ApiMsg<{
status: string;
latencyMs?: number;
xrayVersion?: string;
error?: string;
}>;
}, []);
const probe = useCallback(async (id: number) => {
const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`) as ApiMsg<{
status: string;
latencyMs?: number;
error?: string;
}>;
if (msg?.success) await refresh();
return msg;
}, [refresh]);
const totals = useMemo<NodeTotals>(() => {
let online = 0;
let offline = 0;
let latencySum = 0;
let latencyCount = 0;
let inbounds = 0;
let clients = 0;
let onlineClients = 0;
let depleted = 0;
for (const n of nodes) {
inbounds += n.inboundCount || 0;
clients += n.clientCount || 0;
onlineClients += n.onlineCount || 0;
depleted += n.depletedCount || 0;
if (!n.enable) continue;
if (n.status === 'online') {
online += 1;
if (n.latencyMs && n.latencyMs > 0) {
latencySum += n.latencyMs;
latencyCount += 1;
}
} else if (n.status === 'offline') {
offline += 1;
}
}
return {
total: nodes.length,
online,
offline,
avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
inbounds,
clients,
onlineClients,
depleted,
};
}, [nodes]);
useEffect(() => {
refresh();
}, [refresh]);
return {
nodes,
loading,
fetched,
totals,
refresh,
applyNodesEvent,
create,
update,
remove,
setEnable,
testConnection,
probe,
};
}
+35
View File
@@ -0,0 +1,35 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
import { Status } from '@/models/status';
const POLL_INTERVAL_MS = 2000;
export function useStatus() {
const [status, setStatus] = useState<Status>(() => new Status());
const [fetched, setFetched] = useState(false);
const fetchedRef = useRef(false);
const refresh = useCallback(async () => {
try {
const msg = await HttpUtil.get('/panel/api/server/status');
if (msg?.success) {
setStatus(new Status(msg.obj));
if (!fetchedRef.current) {
fetchedRef.current = true;
setFetched(true);
}
}
} catch (e) {
console.error('Failed to get status:', e);
}
}, []);
useEffect(() => {
refresh();
const timer = window.setInterval(refresh, POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [refresh]);
return { status, fetched, refresh };
}
+136
View File
@@ -0,0 +1,136 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { theme as antdTheme } from 'antd';
import type { ThemeConfig } from 'antd';
const STORAGE_DARK = 'dark-mode';
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
function readBool(key: string, fallback: boolean): boolean {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return raw === 'true';
}
function applyDom(isDark: boolean, isUltra: boolean) {
document.body.setAttribute('class', isDark ? 'dark' : 'light');
if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const msg = document.getElementById('message');
if (msg) msg.className = isDark ? 'dark' : 'light';
}
// module load so the document is in the right theme before React mounts.
const initialDark = readBool(STORAGE_DARK, true);
const initialUltra = readBool(STORAGE_ULTRA, false);
applyDom(initialDark, initialUltra);
const DARK_TOKENS = {
colorBgBase: '#1a1b1f',
colorBgLayout: '#1a1b1f',
colorBgContainer: '#23252b',
colorBgElevated: '#2d2f37',
};
const ULTRA_DARK_TOKENS = {
colorBgBase: '#000',
colorBgLayout: '#000',
colorBgContainer: '#101013',
colorBgElevated: '#1a1a1e',
};
const DARK_LAYOUT_TOKENS = {
bodyBg: '#1a1b1f',
headerBg: '#15161a',
headerColor: '#ffffff',
footerBg: '#1a1b1f',
siderBg: '#15161a',
triggerBg: '#23252b',
triggerColor: '#ffffff',
};
const ULTRA_DARK_LAYOUT_TOKENS = {
bodyBg: '#000',
headerBg: '#050507',
headerColor: '#ffffff',
footerBg: '#000',
siderBg: '#050507',
triggerBg: '#1a1a1e',
triggerColor: '#ffffff',
};
const DARK_MENU_TOKENS = {
darkItemBg: '#15161a',
darkSubMenuItemBg: '#1a1b1f',
darkPopupBg: '#23252b',
};
const ULTRA_DARK_MENU_TOKENS = {
darkItemBg: '#050507',
darkSubMenuItemBg: '#000',
darkPopupBg: '#101013',
};
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
if (!isDark) {
return { algorithm: antdTheme.defaultAlgorithm };
}
return {
algorithm: antdTheme.darkAlgorithm,
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
},
};
}
export function pauseAnimationsUntilLeave(elementId: string): void {
document.documentElement.setAttribute('data-theme-animations', 'off');
const el = document.getElementById(elementId);
if (!el) return;
const restore = () => {
document.documentElement.removeAttribute('data-theme-animations');
el.removeEventListener('mouseleave', restore);
el.removeEventListener('touchend', restore);
};
el.addEventListener('mouseleave', restore);
el.addEventListener('touchend', restore);
}
interface ThemeContextValue {
isDark: boolean;
isUltra: boolean;
toggleTheme: () => void;
toggleUltra: () => void;
antdThemeConfig: ThemeConfig;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(initialDark);
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
useEffect(() => {
applyDom(isDark, isUltra);
localStorage.setItem(STORAGE_DARK, String(isDark));
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
}, [isDark, isUltra]);
const toggleTheme = useCallback(() => setIsDark((v) => !v), []);
const toggleUltra = useCallback(() => setIsUltra((v) => !v), []);
const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]);
const value = useMemo<ThemeContextValue>(
() => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }),
[isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
}

Some files were not shown because too many files have changed in this diff Show More