mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
* refactor(frontend): port api/* and reality-targets to TypeScript
Phase 1 of the JS→TS migration: convert three small, isolated files
(axios-init, websocket, reality-targets) to typed sources so future
phases can lean on their interfaces.
- api/axios-init.ts: typed CSRF cache, interceptors, request retry
- api/websocket.ts: typed listener map, message envelope guard,
reconnect timer
- models/reality-targets.ts: RealityTarget interface, readonly list
- env.d.ts: minimal qs module shim (stringify/parse)
- consumers: drop ".js" extension from @/api imports
* refactor(frontend): port utils/index to TypeScript
Phase 2 of the JS→TS migration: convert the 858-line utility module
that 30+ pages and hooks depend on.
- Msg<T = any> generic with success/msg/obj shape preserved
- HttpUtil get/post/postWithModal generic over response shape
- RandomUtil, Wireguard, Base64 fully typed
- SizeFormatter/CPUFormatter/TimeFormatter/NumberFormatter typed
- ColorUtils.usageColor returns 'green'|'orange'|'red'|'purple' union
- LanguageManager.supportedLanguages readonly typed
- IntlUtil.formatDate/formatRelativeTime accept null/undefined
- ObjectUtil.clone/deepClone/cloneProps/equals kept as `any`-shaped
to preserve the prior JS contract used by class-instance callers
(AllSetting.cloneProps(this, data), etc.)
* refactor(frontend): port models/outbound to TypeScript (hybrid typing)
Phase 4 of the JS→TS migration: rename outbound.js to outbound.ts and
make it compile under strict mode with a minimal hybrid type pass.
- Enum-like constants kept as typed objects (Protocols, SSMethods, …)
- Top-level DNS helpers strictly typed
- CommonClass gets [key: string]: any so all subclasses can keep their
loose this.foo = bar assignments without per-field declarations
- Constructor / fromJson / toJson signatures typed as any to preserve
the prior JS contract used by consumers and parsers
- Outbound declares static fields for the dynamically-attached Settings
subclasses (Settings, FreedomSettings, VmessSettings, …)
- urlParams.get() results that feed parseInt now use the non-null
assertion since the surrounding has() check already guards them
- File-level eslint-disable for no-explicit-any/no-var/prefer-const to
keep the JS-derived code building without churn
* refactor(frontend): port models/inbound to TypeScript (hybrid typing)
Phase 5 of the JS→TS migration. Same hybrid approach as outbound.ts:
constants typed strictly, classes get [key: string]: any from
XrayCommonClass, constructor / fromJson / toJson signatures use any.
- XrayCommonClass gains [key: string]: any plus typed static helpers
(toJsonArray, fallbackToJson, toHeaders, toV2Headers)
- TcpStreamSettings/TlsStreamSettings/RealityStreamSettings/Inbound
declare static fields for their dynamically-attached subclasses
(TcpRequest, TcpResponse, Cert, Settings, ClientBase, Vmess/VLESS/
Trojan/Shadowsocks/Hysteria/Tunnel/Mixed/Http/Wireguard/TunSettings)
- All gen*Link, applyXhttpExtra*, applyExternalProxyTLS*, applyFinalMask*
and related helpers explicitly any-typed
- Constructor positional client-args (email, limitIp, totalGB, …) typed
as optional any across Vmess/VLESS/Trojan/Shadowsocks/Hysteria.VMESS|
VLESS|Trojan|Shadowsocks|Hysteria
- File-level eslint-disable for no-explicit-any/prefer-const/
no-case-declarations/no-array-constructor to silence churn without
changing behavior
* refactor(frontend): port models/dbinbound to TypeScript
Phase 6 — final phase of the JS→TS migration. Frontend src/ no
longer contains any *.js files.
- DBInbound declares all fields explicitly (id, userId, up, down,
total, …, nodeId, fallbackParent) with proper types
- _expiryTime getter/setter typed against dayjs.Dayjs
- coerceInboundJsonField takes unknown, returns any
- Private cache fields (_cachedInbound, _clientStatsMap) declared
- Consumers (InboundFormModal, InboundsPage, useInbounds): drop ".js"
extension from @/models/dbinbound imports
* refactor(frontend): drop .js extensions from TS-resolved imports
Cleanup after the JS→TS migration:
- All consumers that imported @/models/{inbound,outbound,dbinbound}.js
now drop the .js extension (TS module resolution lands on the .ts
file automatically)
- eslint.config.js: remove the **/*.js block since the only remaining
JS file under src/ is endpoints.js (build-script consumed only) and
js.configs.recommended already covers it correctly
* refactor(frontend): tighten inbound.ts cleanup wins
Checkpoint before the full any → typed pass:
- Wrap 15 case bodies in braces (no-case-declarations)
- Convert 14 let → const in genLink helpers (prefer-const)
- new Array() → [] for shadowsocks passwords (no-array-constructor)
- XrayCommonClass: HeaderEntry, FallbackEntry, JsonObject interfaces;
fromJson/toV2Headers/toHeaders typed against them; static methods
return JsonObject / HeaderEntry[] instead of any
- Reduce file-level eslint-disable scope from 4 rules to just
no-explicit-any (the only one still needed)
* refactor(frontend): drop eslint-disable from models/dbinbound
Replace `any` with explicit domain types:
- `coerceInboundJsonField` returns `Record<string, unknown>` (settings/streamSettings/sniffing are always objects).
- Add `RawJsonField`, `ClientStats`, `FallbackParentRef`, `DBInboundInit` types.
- `_cachedInbound: Inbound | null`, `toInbound(): Inbound`.
- `getClientStats(email): ClientStats | undefined`.
- `genInboundLinks(): string` (matches actual return from Inbound.genInboundLinks).
- Constructor now accepts `DBInboundInit`.
* refactor(frontend): drop eslint-disable from InboundsPage
Type all callbacks against DBInbound from @/models/dbinbound:
- state setters use DBInbound | null
- helpers (projectChildThroughMaster, checkFallback, findClientIndex,
exportInboundLinks, etc.) take DBInbound
- drop `(dbInbounds as any[])` casts; useInbounds already returns DBInbound[]
- introduce ClientMatchTarget for findClientIndex's `client` param
- tighten DBInbound.clientStats to ClientStats[] (default [])
- single boundary cast at <InboundList onRowAction=> to bridge
InboundList's narrower DBInboundRecord (cleanup belongs with InboundList)
* refactor(frontend): drop file-level eslint-disable from utils/index
- ObjectUtil.clone/deepClone become generic <T>
- cloneProps/delProps accept `object` (cast internally to AnyRecord)
- equals accepts `unknown` with proper narrowing
- ColorUtils.usageColor narrows data/threshold to `number`; total widened
to `number | { valueOf(): number } | null | undefined` so Dayjs works
- Utils.debounce replaces `const self = this` with lexical arrow
closure (no-this-alias clean)
- InboundList._expiryTime narrowed from `unknown` to `{ valueOf(): number } | null`
- Single-line eslint-disable remains on `Msg<T = any>` and HttpUtil
generic defaults (idiomatic API envelope; changing default to unknown
cascades through 34 consumer files)
* refactor(frontend): drop eslint-disable from OutboundFormModal field section
Replace `type OB = any` with `type OB = Outbound`. Body code still
sees protocol fields as `any` via Outbound's inherited [key: string]: any
index signature (CommonClass) — that escape hatch will narrow as
Phase 6 tightens outbound.ts itself.
The intentional `// eslint-disable-next-line` on `useRef<any>(null)`
at line 72 stays — out of scope per plan.
* refactor(frontend): drop file-level eslint-disable from InboundFormModal
Add minimal local interfaces for protocol-specific shapes the form reads:
- StreamLike, TlsCert, VlessClient, ShadowsocksClient, HttpAccount,
WireguardPeer (replace with real exports from inbound.ts as Phase 7
exports them).
- Props typed as DBInbound | null + DBInbound[].
- Drop unnecessary `(Inbound as any).X`, `(RandomUtil as any).X`,
`(Wireguard as any).X`, `(DBInbound as any)(...)` casts — they are
already typed classes; only `Inbound.Settings`/`Inbound.HttpSettings`
remain `any` via static field on Inbound (will tighten in Phase 7).
- inboundRef/dbFormRef retain single-line `// eslint-disable-next-line`
for `useRef<any>(null)` — nullable narrowing across ~30 callsites
exceeds Phase 5 scope.
- payload locals typed Record<string, unknown>; setAdvancedAllValue
parses JSON into a narrowed object instead of `let parsed: any`.
* refactor(frontend): narrow outbound.ts eslint-disable to no-explicit-any only
- Fix all 36 prefer-const violations: convert never-reassigned `let` to
`const`; for mixed-mutability destructuring (fromParamLink,
fromHysteriaLink) split into separate `const`/`let` declarations
by index instead of destructuring.
- Fix both no-var violations: `var stream` / `var settings` → `let`.
- File still carries `/* eslint-disable @typescript-eslint/no-explicit-any */`
because tightening 223 `any` uses requires removing CommonClass's
`[key: string]: any` escape hatch and reshaping ~30 dynamically-attached
subclass patterns into named classes — multi-hour architectural work
tracked as Phase 7's twin for outbound.
* refactor(frontend): align sub page chrome with login + AntD defaults
- Theme + language buttons now both use AntD `<Button shape="circle"
size="large" className="toolbar-btn">` with TranslationOutlined and
the SVG theme icon — identical hover/border behaviour.
- Language popover content switched from hand-rolled `<ul.lang-list>`
to AntD `<Menu mode="vertical" selectable />`; gains native
hover/keyboard nav + active highlight.
- Drop `.info-table` `!important` border overrides (8 selectors) so
Descriptions inherits the AntD theme border colour.
- Drop `.qr-code` padding/background/border-radius overrides; only
`cursor: pointer` remains (QRCode handles padding/bg itself).
- Remove now-unused `.theme-cycle`, `.lang-list`, `.lang-item*`,
`.lang-select`, `.settings-popover` rules.
* refactor(frontend): drop CustomStatistic wrapper, move overrides to theme tokens
- Delete `<CustomStatistic>` (a pass-through wrapper over <Statistic>)
and its unscoped global `.ant-statistic-*` CSS overrides; consumers
(IndexPage, ClientsPage, InboundsPage, NodesPage) now import AntD
`<Statistic>` directly.
- Add Statistic component tokens to ConfigProvider so the title (11px)
and content (17px) font sizes still apply, without `!important`
global selectors.
- Move dark / ultra-dark card border colours from `body.dark .ant-card`
+ `html[data-theme='ultra-dark'] .ant-card` selectors into Card
`colorBorderSecondary` tokens; page-cards.css now only carries the
custom radius/shadow/transition that has no token equivalent.
- Simplify XrayStatusCard badge: remove the custom `xray-pulse` dot
keyframe and per-state ring-colour overrides; AntD `<Badge
status="processing" color={…}>` already pulses the ring in the same
colour, no extra CSS needed.
* refactor(frontend): modernize login page with AntD primitives
- Theme cycle button switched from `<button.theme-cycle>` + custom CSS
to AntD `<Button shape="circle" className="toolbar-btn">` (matches
sub page chrome already established).
- Theme icons switched from hand-rolled inline SVG (sun, moon,
moon+star) to AntD `<SunOutlined />`, `<MoonOutlined />`,
`<MoonFilled />` for the three light / dark / ultra-dark states.
- Language popover content switched from `<ul.lang-list>` +
`<button.lang-item>` to AntD `<Menu mode="vertical" selectable />`
with `selectedKeys=[lang]`; native hover / keyboard nav / active
highlight come for free.
- Drop CSS for `.theme-cycle`, `.lang-list`, `.lang-item*` (now unused).
`.toolbar-btn` retained since it sizes both circular buttons.
* refactor(frontend): switch sub page theme icons to AntD primitives
Replace the three hand-rolled SVG theme icons (sun, moon, moon+star)
with AntD `<SunOutlined />`, `<MoonOutlined />`, `<MoonFilled />`
for the light / dark / ultra-dark states. Switch the theme `<Button>`
to use the `icon` prop instead of children so it renders the same
way as the language button. Drop `.toolbar-btn svg` CSS — no longer
needed once the icon comes from AntD.
* refactor(frontend): drop !important overrides from pages CSS (Clients + Log modals + Settings tabs)
- ClientsPage: pagination size-changer `min-width !important` removed;
the 3-level selector specificity already beats AntD's defaults.
Scope `body.dark .client-card` to `.clients-page.is-dark .client-card`
(avoid leaking into other pages).
- LogModal + XrayLogModal: move the mobile full-screen tweaks
(`top: 0`, `padding-bottom: 0`, `max-width: 100vw`) from `!important`
class rules to the Modal's `style` prop; keep `.ant-modal-content`
/ `.ant-modal-body` overrides as plain CSS via the className.
- SubscriptionFormatsTab: drop `display: block !important` on
`.nested-block` — div is already block by default.
- TwoFactorModal: drop `padding/background/border-radius !important`
on `.qr-code`; AntD QRCode handles those itself.
* refactor(frontend): scope dark overrides and switch list borders to AntD CSS variables
Scope page-level dark overrides:
- inbounds/InboundList: scope `.ant-table` border-radius rules and the
mobile @media `.ant-card-*` tweaks to `.inbounds-page` (were global
and leaked into other pages); scope `.inbound-card` dark variant to
`.inbounds-page.is-dark`.
- nodes/NodeList: scope `.node-card` dark to `.nodes-page.is-dark`.
- xray/RoutingTab, OutboundsTab: scope `.rule-card`, `.criterion-chip`,
`.criterion-more`, `.address-pill` dark to `.xray-page.is-dark`.
Modernize list borders to use AntD CSS vars instead of body.dark forks:
- index/BackupModal, PanelUpdateModal, VersionModal: replace
hard-coded `rgba(5,5,5,0.06)` + `body.dark`/`html[data-theme]`
override pairs with `var(--ant-color-border-secondary)`; replace
custom text colours with `var(--ant-color-text)` /
`var(--ant-color-text-tertiary)`.
- xray/DnsPresetsModal: same border-color treatment.
- xray/NordModal, WarpModal: collapse `.row-odd` light + `body.dark`
pair into a single neutral `rgba(128,128,128,0.06)` that works on
both themes; scope under `.nord-data-table` / `.warp-data-table`.
* refactor(frontend): switch shared components CSS to AntD CSS variables
Replace body.dark / html[data-theme] forks with AntD CSS variables
in shared components (work in both light and dark, scale to ultra):
- SettingListItem: borders + text colours via
`--ant-color-border-secondary`, `--ant-color-text`,
`--ant-color-text-tertiary`.
- InputAddon: bg/border/text via `--ant-color-fill-tertiary`,
`--ant-color-border`, `--ant-color-text`.
- JsonEditor: host border/bg via `--ant-color-border`,
`--ant-color-bg-container`; focus border via `--ant-color-primary`.
- Sparkline (SVG): grid/text colours via `--ant-color-text*`
and `--ant-color-border-secondary`; only the tooltip drop-shadow
retains a body.dark fork (filter opacity needs explicit value).
* refactor(frontend): swap custom Sparkline SVG for Recharts AreaChart
Replace the 368-line hand-rolled SVG sparkline (with manual
ResizeObserver, gradient/shadow/glow filters, grid + ticks + tooltip,
custom Y-axis label thinning) with a thin Recharts `<AreaChart>`
wrapper that keeps the same prop API.
- Preserved props: data, labels, height, stroke, strokeWidth,
maxPoints, showGrid, fillOpacity, showMarker, markerRadius,
showAxes, yTickStep, tickCountX, showTooltip, valueMin, valueMax,
yFormatter, tooltipFormatter.
- Dropped: `vbWidth`, `gridColor`, `paddingLeft/Right/Top/Bottom` —
Recharts' ResponsiveContainer handles width, and margins are wired
to whether axes are visible. Removed the unused `vbWidth` prop from
SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel callsites.
- Tooltip, grid, and axis text now use AntD CSS variables for
automatic light/dark adaptation; replaced the SVG body.dark forks
in Sparkline.css with a single 5-line stylesheet.
- Bundle: vendor +~100KB gzip (Recharts + its d3 deps), trade-off
for less custom chart code to maintain and a more standard API
for future charts (multi-series, brush, etc.).
* build(frontend): split Recharts + d3 deps into vendor-recharts chunk
Pulls Recharts (~75KB gzip) and its d3-shape/array/color/path/scale
+ victory-vendor deps out of the catch-all vendor chunk so they
load on demand on the three pages that use Sparkline
(SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) and cache
independently from the rest of the panel JS.
* refactor(frontend): drop body.dark forks in favor of AntD CSS variables
- ClientInfoModal/InboundInfoModal: link-panel-text and link-panel-anchor now use
var(--ant-color-fill-tertiary) and color-mix on --ant-color-primary, removing
the body.dark light/dark background pair.
- InboundFormModal: advanced-panel uses --ant-color-border-secondary and
--ant-color-fill-quaternary; body.dark/html[data-theme='ultra-dark'] pair gone.
- CustomGeoSection: custom-geo-count, custom-geo-ext-code, custom-geo-copyable:hover
use --ant-color-fill-tertiary/-secondary; body.dark forks gone.
- SystemHistoryModal: cpu-chart-wrap collapsed from three theme-specific gradients
into one using color-mix on --ant-color-primary and --ant-color-fill-quaternary.
- page-cards.css: body.dark / html[data-theme='ultra-dark'] selectors renamed to
page-scoped .is-dark / .is-dark.is-ultra, keeping the same shadow tuning but
consistent with the page-scoping convention used elsewhere.
* refactor(sidebar): modernize AppSidebar with AntD CSS variables and icons
- Replace hardcoded rgba(0,0,0,X) colors with var(--ant-color-text)
and var(--ant-color-text-secondary) so light/dark adapt automatically.
- Replace rgba(128,128,128,0.15) borders with var(--ant-color-border-secondary)
and rgba(128,128,128,0.18) backgrounds with var(--ant-color-fill-tertiary).
- Drop all body.dark/html[data-theme='ultra-dark'] color forks for
.drawer-brand, .sider-brand, .drawer-close, .sidebar-theme-cycle,
.sidebar-donate (CSS variables already adapt).
- Drop the body.dark Drawer background !important pair; AntD's
colorBgElevated token from the dark algorithm handles it now.
- Replace inline sun/moon SVGs in ThemeCycleButton with AntD's
SunOutlined/MoonOutlined/MoonFilled to match LoginPage/SubPage.
- Convert .sidebar-theme-cycle hover and the menu item selected/hover
highlights from hardcoded #4096ff to color-mix on --ant-color-primary,
keeping !important on menu rules to beat AntD's CSS-in-JS specificity.
* refactor(frontend): swap hardcoded AntD palette colors for CSS variables
The dot/badge/pill styles still hardcoded AntD's default palette values
(#52c41a, #1677ff, #ff4d4f, #fa8c16, #ff4d4f). Replace each with its
semantic --ant-color-* equivalent so they auto-adapt to any theme
customization through ConfigProvider.
- ClientsPage: .dot-green/.dot-blue/.dot-red/.dot-orange/.dot-gray now
use --ant-color-success / -primary / -error / -warning / -text-quaternary.
.bulk-count / .client-card / .client-card.is-selected backgrounds use
color-mix on --ant-color-primary and --ant-color-fill-quaternary, which
also let the body-dark .client-card fork go away.
- XrayMetricsModal: .obs-dot is-alive/is-dead and its pulse keyframe now
build their box-shadow tint via color-mix on --ant-color-success and
--ant-color-error instead of rgba literals.
- IndexPage: .action-update warning color uses --ant-color-warning.
- OutboundsTab: .outbound-card border, .address-pill background, and
.mode-badge tint now use AntD CSS variables; the .xray-page.is-dark
.address-pill fork is gone.
- InboundFormModal/InboundsPage/ClientBulkAddModal: drop the stale
`, #1677ff`/`, #1890ff` fallbacks on var(--ant-color-primary), and
switch .danger-icon to --ant-color-error.
The teal/cyan brand colors (#008771, #3c89e8, #e04141) used by traffic
and pill rows are intentionally kept hardcoded — they are brand-specific
shades, not AntD palette colors.
* refactor(frontend): swap neutral gray rgba literals for AntD CSS variables
Across 12 files the same neutral grays kept reappearing — rgba(128,128,128,
0.06|0.08|0.12|0.15|0.18|0.2|0.25) for borders, dividers, and subtle
backgrounds. Each maps cleanly to an AntD CSS variable that already
adapts to light/dark and to any theme customization through ConfigProvider:
- 0.12–0.18 borders → var(--ant-color-border-secondary)
- 0.2–0.25 borders → var(--ant-color-border)
- 0.06–0.08 backgrounds → var(--ant-color-fill-tertiary)
- 0.02–0.03 card surfaces → var(--ant-color-fill-quaternary)
Card surfaces (InboundList .inbound-card, NodeList .node-card) had a
light/dark fork pair — the variable covers both, so the .is-dark .card
override is gone.
RoutingTab .rule-card.drop-before/after used hardcoded #1677ff for the
inset focus shadow; replaced with var(--ant-color-primary) so reordering
indicators follow the theme primary.
ClientsPage bucketBadgeColor returned hex literals (#ff4d4f, #fa8c16,
#52c41a, rgba gray) for a Badge color prop. Switched to status="error"|
"warning"|"success"|"default" so the dot color now comes from AntD's
semantic palette directly.
* refactor(xray): collapse RoutingTab dark forks into AntD CSS variables
- .criterion-more bg light/dark fork → var(--ant-color-fill-tertiary)
- .xray-page.is-dark .rule-card and .criterion-chip overrides removed;
the rules already use --bg-card and --ant-color-fill-tertiary that
adapt to the theme on their own.
* refactor(frontend): inline style hex literals and Alert icon redundancy
- FinalMaskForm: five DeleteOutlined icons used rgb(255,77,79) inline;
swap for var(--ant-color-error) so they follow theme customization.
- NodesPage: CheckCircleOutlined / CloseCircleOutlined statistic prefixes
switch to var(--ant-color-success) / -error.
- NodeList: ExclamationCircleOutlined warning icons (two callsites) now
use var(--ant-color-warning).
- BasicsTab: four <Alert type="warning"> blocks shipped a custom
ExclamationCircleFilled icon styled to match the warning palette —
exactly the icon and color AntD Alert renders for type="warning" by
default. Replace the icon prop with showIcon and drop the now-unused
ExclamationCircleFilled import.
- JsonEditor: focus-within box-shadow tint now uses color-mix on
--ant-color-primary instead of an rgba(22,119,255,0.1) literal.
* refactor(logs): collapse log-container dark forks to AntD CSS variables
LogModal and XrayLogModal each had a body.dark fork that overrode the
log container's background, border-color, and text color in addition
to the --log-* severity tokens. Background/border/color all map cleanly
to var(--ant-color-fill-tertiary) / var(--ant-color-border) /
var(--ant-color-text) which already adapt to the theme, so only the
severity color tokens remain inside the dark/ultra-dark blocks.
* refactor(xray): drop stale --ant-primary-color fallbacks and hex literals
- RoutingTab .drop-before/.drop-after box-shadow: #1677ff → var(--ant-color-primary)
- OutboundFormModal .random-icon: drop the --ant-primary-color/#1890ff
pair (the old AntD v4 token name with stale fallback) for the v6
--ant-color-primary; .danger-icon hex #ff4d4f → var(--ant-color-error).
- XrayPage .restart-icon: same drop of the --ant-primary-color fallback.
These were all leftovers from the AntD v4 → v6 rename — the v6
--ant-color-primary is already populated by ConfigProvider, so the
fallback hex was dead code that would only trigger if AntD wasn't
mounted.
* refactor(frontend): consolidate margin utility classes into one stylesheet
Page CSS files each carried their own copies of the same atomic margin
utilities (.mt-4, .mt-8, .mb-12, .ml-8, .my-10, ...). The definitions
were identical everywhere they appeared, with each file holding only
the subset it happened to need.
Move all of them into a single styles/utils.css imported once from
main.tsx, and delete the per-page copies from InboundFormModal,
CustomGeoSection, PanelUpdateModal, VersionModal, BasicsTab, NordModal,
OutboundFormModal, and WarpModal. The classes are available globally
on the panel app; login.tsx and subpage.tsx entries do not consume any
of them so they stay untouched.
* refactor(frontend): consolidate shared page-shell rules into one stylesheet
Every panel page CSS file repeated the same wrapper boilerplate — the
--bg-page/--bg-card token triples for light/dark/ultra-dark, the
min-height + background root rule, the .ant-layout transparent reset,
the .content-shell transparent reset, and the .loading-spacer min-height.
That's ~30 identical lines duplicated across IndexPage, ClientsPage,
InboundsPage, XrayPage, SettingsPage, NodesPage, and ApiDocsPage.
Move all of it into styles/page-shell.css and import it once from
main.tsx alongside utils.css and page-cards.css. Each page CSS file
now only contains genuinely page-specific rules (content-area padding
overrides, page-specific tokens like ApiDocs's Swagger --sw-* set).
Also drop the per-page `import '@/styles/page-cards.css'` statements
from the 7 page tsx files now that main.tsx loads it globally.
Net: -211 deleted, +6 inserted in the touched files, plus the new
page-shell.css. .zero-margin (Divider override used by Nord/Warp
modals) folded into utils.css alongside the margin classes.
* refactor(frontend): move default content-area padding to page-shell.css
After page-shell.css landed, six of the seven panel pages still kept an
identical `.X-page .content-area { padding: 24px }` desktop rule, plus
three of them kept an identical `padding: 8px` mobile rule. Hoist both
defaults into page-shell.css under a single 6-page selector group and
delete the per-page copies.
What stays page-specific:
- IndexPage keeps its mobile override (padding 12px + padding-top: 64px
for the fixed drawer handle clearance).
- ApiDocsPage keeps its tighter desktop padding (16px) and its own
mobile padding-top: 56px.
Settings .ldap-no-inbounds also switches from #999 to
var(--ant-color-text-tertiary) for theme adaptation.
* refactor(frontend): hoist .header-row, .icons-only, .summary-card to page-shell.css
Settings and Xray pages both carried identical .header-row /
.header-actions / .header-info rules and an identical six-rule
.icons-only block that styles tabbed page navigation. Clients, Inbounds,
and Nodes all carried identical .summary-card padding rules with the
same mobile reduction. None of these are page-specific.
Consolidate:
- .header-row family → page-shell scoped to .settings-page, .xray-page
- .icons-only family → page-shell global (the class is a deliberate
opt-in marker, no scope needed)
- .summary-card → page-shell scoped to .clients-page, .inbounds-page,
.nodes-page (also fixes InboundsPage's missing scope — its rule was
global and would have matched stray .summary-card uses elsewhere)
InboundsPage.css and NodesPage.css became empty after the move so the
files and their per-page imports are deleted.
* refactor(frontend): hoist .random-icon to utils.css
Three form modals each carried identical .random-icon styles (small
primary-tinted icon next to randomizable inputs):
ClientBulkAddModal, InboundFormModal, OutboundFormModal
Single definition lives in utils.css now. ClientBulkAddModal.css was
just this one rule, so the file and its import are deleted along the way.
.danger-icon is left per file — the margin-left differs slightly
between InboundFormModal (6px) and OutboundFormModal (8px), so it
stays as a page-local rule rather than getting averaged into utils.css.
* refactor(frontend): hoist .danger-icon to utils.css and use it everywhere
InboundFormModal (margin-left 6px) and OutboundFormModal (margin-left
8px) each carried their own .danger-icon, and FinalMaskForm wrote the
same color/cursor/marginLeft trio inline five times. Unify on a single
.danger-icon in utils.css with margin-left: 8px — matching the more
generous OutboundFormModal value — and:
- Drop the per-file .danger-icon copies from InboundFormModal.css and
OutboundFormModal.css.
- Replace the five inline style props in FinalMaskForm.tsx with
className="danger-icon".
The visible change is a 2px wider gap to the right of the delete icons
on InboundFormModal's protocol/peer dividers.
647 lines
22 KiB
TypeScript
647 lines
22 KiB
TypeScript
import { useCallback, useMemo, useState, type ReactElement } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Button,
|
|
Card,
|
|
Dropdown,
|
|
Modal,
|
|
Popover,
|
|
Space,
|
|
Switch,
|
|
Table,
|
|
Tag,
|
|
Tooltip,
|
|
type TableColumnType,
|
|
type MenuProps,
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined,
|
|
MenuOutlined,
|
|
MoreOutlined,
|
|
EditOutlined,
|
|
QrcodeOutlined,
|
|
CopyOutlined,
|
|
ExportOutlined,
|
|
ImportOutlined,
|
|
ReloadOutlined,
|
|
RetweetOutlined,
|
|
BlockOutlined,
|
|
DeleteOutlined,
|
|
InfoCircleOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
|
import InfinityIcon from '@/components/InfinityIcon';
|
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
|
import './InboundList.css';
|
|
|
|
type ProtocolFlags = {
|
|
isVMess?: boolean;
|
|
isVLess?: boolean;
|
|
isTrojan?: boolean;
|
|
isSS?: boolean;
|
|
isHysteria?: boolean;
|
|
isMixed?: boolean;
|
|
isHTTP?: boolean;
|
|
isWireguard?: boolean;
|
|
};
|
|
|
|
interface DBInboundRecord extends ProtocolFlags {
|
|
id: number;
|
|
enable: boolean;
|
|
remark: string;
|
|
port: number;
|
|
protocol: string;
|
|
up: number;
|
|
down: number;
|
|
total: number;
|
|
expiryTime: number;
|
|
_expiryTime: { valueOf(): number } | null;
|
|
nodeId?: number | null;
|
|
toInbound: () => {
|
|
stream?: { network?: string; isTls?: boolean; isReality?: boolean };
|
|
isSSMultiUser?: boolean;
|
|
};
|
|
isMultiUser: () => boolean;
|
|
}
|
|
|
|
export interface ClientCountEntry {
|
|
clients: number;
|
|
active: string[];
|
|
deactive: string[];
|
|
depleted: string[];
|
|
expiring: string[];
|
|
online: string[];
|
|
}
|
|
|
|
export type RowAction =
|
|
| 'edit'
|
|
| 'showInfo'
|
|
| 'qrcode'
|
|
| 'export'
|
|
| 'subs'
|
|
| 'clipboard'
|
|
| 'delete'
|
|
| 'resetTraffic'
|
|
| 'clone';
|
|
|
|
export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
|
|
|
interface InboundListProps {
|
|
dbInbounds: DBInboundRecord[];
|
|
clientCount: Record<number, ClientCountEntry>;
|
|
onlineClients: string[];
|
|
lastOnlineMap: Record<string, number>;
|
|
expireDiff: number;
|
|
trafficDiff: number;
|
|
pageSize: number;
|
|
isMobile: boolean;
|
|
subEnable: boolean;
|
|
nodesById: Map<number, NodeRecord>;
|
|
hasActiveNode: boolean;
|
|
onAddInbound: () => void;
|
|
onGeneralAction: (key: GeneralAction) => void;
|
|
onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
|
|
}
|
|
|
|
type SortKey =
|
|
| 'id'
|
|
| 'enable'
|
|
| 'remark'
|
|
| 'port'
|
|
| 'protocol'
|
|
| 'traffic'
|
|
| 'expiryTime'
|
|
| 'node'
|
|
| 'clients';
|
|
|
|
type SortOrder = 'ascend' | 'descend' | null;
|
|
|
|
const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
|
|
id: (a, b) => a.id - b.id,
|
|
enable: (a, b) => Number(a.enable) - Number(b.enable),
|
|
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
|
|
port: (a, b) => a.port - b.port,
|
|
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
|
|
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
|
|
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
|
|
node: (a, b, ctx) => {
|
|
const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '' : `node #${a.nodeId}`);
|
|
const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '' : `node #${b.nodeId}`);
|
|
return nameA.localeCompare(nameB);
|
|
},
|
|
clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
|
|
};
|
|
|
|
function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
|
if (dbInbound.isWireguard) return true;
|
|
if (dbInbound.isSS) {
|
|
try {
|
|
return !dbInbound.toInbound().isSSMultiUser;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
interface RowActionsMenuProps {
|
|
record: DBInboundRecord;
|
|
subEnable: boolean;
|
|
onClick: (key: RowAction) => void;
|
|
isMobile?: boolean;
|
|
}
|
|
|
|
function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean }): MenuProps['items'] {
|
|
const items: MenuProps['items'] = [];
|
|
if (isMobile) {
|
|
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
|
|
}
|
|
if (showQrCodeMenu(record)) {
|
|
items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
|
|
}
|
|
if (record.isMultiUser()) {
|
|
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
|
|
if (subEnable) {
|
|
items.push({
|
|
key: 'subs',
|
|
icon: <ExportOutlined />,
|
|
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
|
|
});
|
|
}
|
|
} else {
|
|
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('info') });
|
|
}
|
|
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
|
|
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
|
|
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
|
|
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
|
|
return items;
|
|
}
|
|
|
|
function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="action-buttons">
|
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
|
|
<Dropdown
|
|
trigger={['click']}
|
|
menu={{
|
|
items: buildRowActionsMenu({ record, subEnable, t }),
|
|
onClick: ({ key }) => onClick(key as RowAction),
|
|
}}
|
|
>
|
|
<Button type="text" size="small" icon={<MoreOutlined />} />
|
|
</Dropdown>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function InboundList({
|
|
dbInbounds,
|
|
clientCount,
|
|
lastOnlineMap: _lastOnlineMap,
|
|
expireDiff,
|
|
trafficDiff,
|
|
pageSize,
|
|
isMobile,
|
|
subEnable,
|
|
nodesById,
|
|
hasActiveNode,
|
|
onAddInbound,
|
|
onGeneralAction,
|
|
onRowAction,
|
|
}: InboundListProps) {
|
|
const { t } = useTranslation();
|
|
const { datepicker } = useDatepicker();
|
|
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
|
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
|
|
|
|
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
|
|
const previous = dbInbound.enable;
|
|
dbInbound.enable = next;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('enable', String(next));
|
|
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
|
|
if (!msg?.success) dbInbound.enable = previous;
|
|
} catch {
|
|
dbInbound.enable = previous;
|
|
}
|
|
}, []);
|
|
|
|
const sortedInbounds = useMemo(() => {
|
|
if (!sortKey || !sortOrder) return dbInbounds;
|
|
const fn = SORT_FNS[sortKey];
|
|
if (!fn) return dbInbounds;
|
|
const sorted = [...dbInbounds].sort((a, b) => fn(a, b, { nodesById, clientCount }));
|
|
return sortOrder === 'descend' ? sorted.reverse() : sorted;
|
|
}, [dbInbounds, sortKey, sortOrder, nodesById, clientCount]);
|
|
|
|
const hasAnyRemark = useMemo(
|
|
() => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
|
|
[dbInbounds],
|
|
);
|
|
|
|
const sorterFor = useCallback((key: SortKey) => ({
|
|
sorter: true as const,
|
|
showSorterTooltip: false,
|
|
sortOrder: sortKey === key ? sortOrder : null,
|
|
sortDirections: ['ascend' as const, 'descend' as const],
|
|
}), [sortKey, sortOrder]);
|
|
|
|
const columns: TableColumnType<DBInboundRecord>[] = useMemo(() => {
|
|
const cols: TableColumnType<DBInboundRecord>[] = [
|
|
{
|
|
title: 'ID',
|
|
dataIndex: 'id',
|
|
key: 'id',
|
|
align: 'right',
|
|
width: 30,
|
|
...sorterFor('id'),
|
|
},
|
|
{
|
|
title: t('pages.inbounds.operate'),
|
|
key: 'action',
|
|
align: 'center',
|
|
width: 60,
|
|
render: (_, record) => (
|
|
<RowActionsCell
|
|
record={record}
|
|
subEnable={subEnable}
|
|
onClick={(key) => onRowAction({ key, dbInbound: record })}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: t('pages.inbounds.enable'),
|
|
key: 'enable',
|
|
align: 'center',
|
|
width: 35,
|
|
...sorterFor('enable'),
|
|
render: (_, record) => (
|
|
<Switch
|
|
checked={record.enable}
|
|
onChange={(next) => onSwitchEnable(record, next)}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (hasAnyRemark) {
|
|
cols.push({
|
|
title: t('pages.inbounds.remark'),
|
|
dataIndex: 'remark',
|
|
key: 'remark',
|
|
align: 'center',
|
|
width: 60,
|
|
...sorterFor('remark'),
|
|
});
|
|
}
|
|
|
|
if (hasActiveNode) {
|
|
cols.push({
|
|
title: t('pages.inbounds.node'),
|
|
key: 'node',
|
|
align: 'center',
|
|
width: 60,
|
|
...sorterFor('node'),
|
|
render: (_, record) => {
|
|
if (record.nodeId == null) {
|
|
return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
|
|
}
|
|
const node = nodesById.get(record.nodeId);
|
|
if (!node) {
|
|
return <Tag color="orange">node #{record.nodeId}</Tag>;
|
|
}
|
|
return (
|
|
<Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
cols.push(
|
|
{
|
|
title: t('pages.inbounds.port'),
|
|
dataIndex: 'port',
|
|
key: 'port',
|
|
align: 'center',
|
|
width: 40,
|
|
...sorterFor('port'),
|
|
},
|
|
{
|
|
title: t('pages.inbounds.protocol'),
|
|
key: 'protocol',
|
|
align: 'left',
|
|
width: 130,
|
|
...sorterFor('protocol'),
|
|
render: (_, record) => {
|
|
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
|
|
if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) {
|
|
const stream = record.toInbound().stream;
|
|
tags.push(
|
|
<Tag key="n" color="green">
|
|
{record.isHysteria ? 'UDP' : stream?.network}
|
|
</Tag>,
|
|
);
|
|
if (stream?.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
|
|
if (stream?.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
|
|
}
|
|
return <div className="protocol-tags">{tags}</div>;
|
|
},
|
|
},
|
|
{
|
|
title: t('clients'),
|
|
key: 'clients',
|
|
align: 'left',
|
|
width: 50,
|
|
...sorterFor('clients'),
|
|
render: (_, record) => {
|
|
const cc = clientCount[record.id];
|
|
if (!cc) return null;
|
|
return (
|
|
<>
|
|
<Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
|
{cc.clients}
|
|
</Tag>
|
|
{cc.deactive.length > 0 && (
|
|
<Popover
|
|
title={t('disabled')}
|
|
content={(
|
|
<div className="client-email-list">
|
|
{cc.deactive.map((e) => <div key={e}>{e}</div>)}
|
|
</div>
|
|
)}
|
|
>
|
|
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
|
|
</Popover>
|
|
)}
|
|
{cc.depleted.length > 0 && (
|
|
<Popover
|
|
title={t('depleted')}
|
|
content={(
|
|
<div className="client-email-list">
|
|
{cc.depleted.map((e) => <div key={e}>{e}</div>)}
|
|
</div>
|
|
)}
|
|
>
|
|
<Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
|
|
</Popover>
|
|
)}
|
|
{cc.expiring.length > 0 && (
|
|
<Popover
|
|
title={t('depletingSoon')}
|
|
content={(
|
|
<div className="client-email-list">
|
|
{cc.expiring.map((e) => <div key={e}>{e}</div>)}
|
|
</div>
|
|
)}
|
|
>
|
|
<Tag color="orange" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.expiring.length}</Tag>
|
|
</Popover>
|
|
)}
|
|
{cc.online.length > 0 && (
|
|
<Popover
|
|
title={t('online')}
|
|
content={(
|
|
<div className="client-email-list">
|
|
{cc.online.map((e) => <div key={e}>{e}</div>)}
|
|
</div>
|
|
)}
|
|
>
|
|
<Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
|
|
</Popover>
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: t('pages.inbounds.traffic'),
|
|
key: 'traffic',
|
|
align: 'center',
|
|
width: 90,
|
|
...sorterFor('traffic'),
|
|
render: (_, record) => (
|
|
<Popover
|
|
content={(
|
|
<table cellPadding={2}>
|
|
<tbody>
|
|
<tr>
|
|
<td>↑ {SizeFormatter.sizeFormat(record.up)}</td>
|
|
<td>↓ {SizeFormatter.sizeFormat(record.down)}</td>
|
|
</tr>
|
|
{record.total > 0 && record.up + record.down < record.total && (
|
|
<tr>
|
|
<td>{t('remained')}</td>
|
|
<td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
>
|
|
<Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
|
|
{SizeFormatter.sizeFormat(record.up + record.down)} /
|
|
{' '}
|
|
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
|
|
</Tag>
|
|
</Popover>
|
|
),
|
|
},
|
|
{
|
|
title: t('pages.inbounds.expireDate'),
|
|
key: 'expiryTime',
|
|
align: 'center',
|
|
width: 40,
|
|
...sorterFor('expiryTime'),
|
|
render: (_, record) => {
|
|
if (record.expiryTime > 0) {
|
|
return (
|
|
<Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
|
|
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
|
|
{IntlUtil.formatRelativeTime(record.expiryTime)}
|
|
</Tag>
|
|
</Popover>
|
|
);
|
|
}
|
|
return <Tag color="purple"><InfinityIcon /></Tag>;
|
|
},
|
|
},
|
|
);
|
|
|
|
return cols;
|
|
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable, sorterFor]);
|
|
|
|
const paginationFor = (rows: DBInboundRecord[]) => {
|
|
const size = pageSize > 0 ? pageSize : rows.length || 1;
|
|
return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
|
|
};
|
|
|
|
const generalActionsMenu: MenuProps = {
|
|
items: [
|
|
{ key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
|
|
{ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
|
|
...(subEnable
|
|
? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
|
|
: []),
|
|
{ key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
|
|
],
|
|
onClick: ({ key }) => onGeneralAction(key as GeneralAction),
|
|
};
|
|
|
|
return (
|
|
<Card
|
|
hoverable
|
|
title={(
|
|
<Space>
|
|
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
|
|
{!isMobile && t('pages.inbounds.addInbound')}
|
|
</Button>
|
|
<Dropdown trigger={['click']} menu={generalActionsMenu}>
|
|
<Button type="primary" icon={<MenuOutlined />}>
|
|
{!isMobile && t('pages.inbounds.generalActions')}
|
|
</Button>
|
|
</Dropdown>
|
|
</Space>
|
|
)}
|
|
>
|
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
|
{isMobile ? (
|
|
<div className="inbound-cards">
|
|
{sortedInbounds.length === 0 ? (
|
|
<div className="card-empty">—</div>
|
|
) : (
|
|
sortedInbounds.map((record) => (
|
|
<div key={record.id} className="inbound-card">
|
|
<div className="card-head">
|
|
<span className="card-id">#{record.id}</span>
|
|
<span className="tag-name">{record.remark}</span>
|
|
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
|
<Tooltip title={t('info')}>
|
|
<InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
|
|
</Tooltip>
|
|
<Switch
|
|
checked={record.enable}
|
|
size="small"
|
|
onChange={(next) => onSwitchEnable(record, next)}
|
|
/>
|
|
<Dropdown
|
|
trigger={['click']}
|
|
placement="bottomRight"
|
|
menu={{
|
|
items: buildRowActionsMenu({ record, subEnable, t, isMobile: true }),
|
|
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
|
|
}}
|
|
>
|
|
<MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
|
|
</Dropdown>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Table
|
|
columns={columns}
|
|
dataSource={sortedInbounds}
|
|
rowKey={(r) => r.id}
|
|
pagination={paginationFor(sortedInbounds)}
|
|
scroll={{ x: 1000 }}
|
|
style={{ marginTop: 10 }}
|
|
size="small"
|
|
onChange={(_p, _f, sorter) => {
|
|
const single = Array.isArray(sorter) ? sorter[0] : sorter;
|
|
const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
|
|
setSortKey(colKey || null);
|
|
setSortOrder((single?.order as SortOrder) || null);
|
|
}}
|
|
/>
|
|
)}
|
|
</Space>
|
|
|
|
<Modal
|
|
open={isMobile && !!statsRecord}
|
|
footer={null}
|
|
width={360}
|
|
centered
|
|
title={statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''}
|
|
onCancel={() => setStatsRecord(null)}
|
|
destroyOnHidden
|
|
>
|
|
{statsRecord && (
|
|
<div className="card-stats">
|
|
<div className="stat-row">
|
|
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
|
|
<Tag color="purple">{statsRecord.protocol}</Tag>
|
|
{(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (
|
|
<>
|
|
<Tag color="green">
|
|
{statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream?.network}
|
|
</Tag>
|
|
{statsRecord.toInbound().stream?.isTls && <Tag color="blue">TLS</Tag>}
|
|
{statsRecord.toInbound().stream?.isReality && <Tag color="blue">Reality</Tag>}
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="stat-row">
|
|
<span className="stat-label">{t('pages.inbounds.port')}</span>
|
|
<Tag>{statsRecord.port}</Tag>
|
|
</div>
|
|
{hasActiveNode && (
|
|
<div className="stat-row">
|
|
<span className="stat-label">{t('pages.inbounds.node')}</span>
|
|
{statsRecord.nodeId == null ? (
|
|
<Tag color="default">{t('pages.inbounds.localPanel')}</Tag>
|
|
) : nodesById.get(statsRecord.nodeId) ? (
|
|
<Tag color={nodesById.get(statsRecord.nodeId)!.status === 'online' ? 'blue' : 'red'}>
|
|
{nodesById.get(statsRecord.nodeId)!.name}
|
|
</Tag>
|
|
) : (
|
|
<Tag color="orange">#{statsRecord.nodeId}</Tag>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="stat-row">
|
|
<span className="stat-label">{t('pages.inbounds.traffic')}</span>
|
|
<Tag color={ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)}>
|
|
{SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down)} /
|
|
{' '}
|
|
{statsRecord.total > 0 ? SizeFormatter.sizeFormat(statsRecord.total) : <InfinityIcon />}
|
|
</Tag>
|
|
</div>
|
|
{clientCount[statsRecord.id] && (
|
|
<div className="stat-row">
|
|
<span className="stat-label">{t('clients')}</span>
|
|
<Tag color="green" className="client-count-tag">{clientCount[statsRecord.id].clients}</Tag>
|
|
{clientCount[statsRecord.id].online.length > 0 && (
|
|
<Tag color="blue">{clientCount[statsRecord.id].online.length} {t('online')}</Tag>
|
|
)}
|
|
{clientCount[statsRecord.id].depleted.length > 0 && (
|
|
<Tag color="red">{clientCount[statsRecord.id].depleted.length} {t('depleted')}</Tag>
|
|
)}
|
|
{clientCount[statsRecord.id].expiring.length > 0 && (
|
|
<Tag color="orange">{clientCount[statsRecord.id].expiring.length} {t('depletingSoon')}</Tag>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="stat-row">
|
|
<span className="stat-label">{t('pages.inbounds.expireDate')}</span>
|
|
{statsRecord.expiryTime > 0 ? (
|
|
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)}>
|
|
{IntlUtil.formatRelativeTime(statsRecord.expiryTime)}
|
|
</Tag>
|
|
) : (
|
|
<Tag color="purple"><InfinityIcon /></Tag>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</Card>
|
|
);
|
|
}
|