mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
Migrate frontend models/api/utils to TypeScript and modernize AntD theming (#4563)
* 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.
This commit is contained in:
parent
19e88c4610
commit
dc37f9b731
93 changed files with 2961 additions and 3755 deletions
|
|
@ -6,26 +6,6 @@ import globals from 'globals';
|
||||||
export default [
|
export default [
|
||||||
{ ignores: ['node_modules/**', '../web/dist/**'] },
|
{ ignores: ['node_modules/**', '../web/dist/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
{
|
|
||||||
files: ['**/*.js'],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2022,
|
|
||||||
sourceType: 'module',
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'no-unused-vars': ['warn', {
|
|
||||||
argsIgnorePattern: '^_',
|
|
||||||
varsIgnorePattern: '^_',
|
|
||||||
caughtErrorsIgnorePattern: '^_',
|
|
||||||
}],
|
|
||||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
|
||||||
'no-case-declarations': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...tseslint.configs.recommended.map((config) => ({
|
...tseslint.configs.recommended.map((config) => ({
|
||||||
...config,
|
...config,
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
|
@ -50,16 +30,6 @@ export default [
|
||||||
caughtErrorsIgnorePattern: '^_',
|
caughtErrorsIgnorePattern: '^_',
|
||||||
}],
|
}],
|
||||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
|
||||||
// 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/set-state-in-effect': 'off',
|
||||||
'react-hooks/purity': 'off',
|
'react-hooks/purity': 'off',
|
||||||
'react-hooks/react-compiler': 'off',
|
'react-hooks/react-compiler': 'off',
|
||||||
|
|
|
||||||
347
frontend/package-lock.json
generated
347
frontend/package-lock.json
generated
|
|
@ -25,6 +25,7 @@
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.15.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"swagger-ui-react": "^5.32.6"
|
"swagger-ui-react": "^5.32.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1571,6 +1572,42 @@
|
||||||
"react-dom": ">=18.0.0"
|
"react-dom": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||||
|
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||||
|
|
@ -1860,6 +1897,18 @@
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swagger-api/apidom-ast": {
|
"node_modules/@swagger-api/apidom-ast": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.11.1.tgz",
|
||||||
|
|
@ -2583,6 +2632,69 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
|
|
@ -3456,6 +3568,127 @@
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.20",
|
"version": "1.11.20",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||||
|
|
@ -3479,6 +3712,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
|
|
@ -3637,6 +3876,16 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.46.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
|
||||||
|
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -3832,6 +4081,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -4302,6 +4557,16 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "3.8.3",
|
"version": "3.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz",
|
||||||
|
|
@ -4327,6 +4592,15 @@
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/invariant": {
|
"node_modules/invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
|
@ -5537,6 +5811,42 @@
|
||||||
"react": ">= 0.14.0"
|
"react": ">= 0.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts/node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/redux": {
|
"node_modules/redux": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
|
@ -5552,6 +5862,15 @@
|
||||||
"immutable": "^3.8.1 || ^4.0.0-rc.1"
|
"immutable": "^3.8.1 || ^4.0.0-rc.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/refractor": {
|
"node_modules/refractor": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||||
|
|
@ -6028,6 +6347,12 @@
|
||||||
"node": ">=12.22"
|
"node": ">=12.22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
|
|
@ -6280,6 +6605,28 @@
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.13",
|
"version": "8.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.15.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"swagger-ui-react": "^5.32.6"
|
"swagger-ui-react": "^5.32.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||||
|
|
||||||
let csrfToken = null;
|
let csrfToken: string | null = null;
|
||||||
let csrfFetchPromise = null;
|
let csrfFetchPromise: Promise<string | null> | null = null;
|
||||||
let sessionExpired = false;
|
let sessionExpired = false;
|
||||||
|
|
||||||
function readMetaToken() {
|
type CsrfAwareConfig = InternalAxiosRequestConfig & { __csrfRetried?: boolean };
|
||||||
|
|
||||||
|
function readMetaToken(): string | null {
|
||||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCsrfToken() {
|
async function fetchCsrfToken(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const basePath = window.X_UI_BASE_PATH;
|
const basePath = window.X_UI_BASE_PATH;
|
||||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||||
|
|
@ -24,14 +27,14 @@ async function fetchCsrfToken() {
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const json = await res.json();
|
const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
|
||||||
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||||
} catch (_e) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureCsrfToken() {
|
async function ensureCsrfToken(): Promise<string | null> {
|
||||||
if (csrfToken) return csrfToken;
|
if (csrfToken) return csrfToken;
|
||||||
const meta = readMetaToken();
|
const meta = readMetaToken();
|
||||||
if (meta) {
|
if (meta) {
|
||||||
|
|
@ -45,14 +48,11 @@ async function ensureCsrfToken() {
|
||||||
return csrfToken;
|
return csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the panel's axios defaults + interceptors. Call once at app
|
export function setupAxios(): void {
|
||||||
// startup before any HTTP call goes out.
|
|
||||||
export function setupAxios() {
|
|
||||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||||
|
|
||||||
// Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
|
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
||||||
let basePath = window.X_UI_BASE_PATH;
|
|
||||||
if (!basePath) {
|
if (!basePath) {
|
||||||
const metaTag = document.querySelector('meta[name="base-path"]');
|
const metaTag = document.querySelector('meta[name="base-path"]');
|
||||||
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
basePath = metaTag ? metaTag.getAttribute('content') : null;
|
||||||
|
|
@ -61,22 +61,19 @@ export function setupAxios() {
|
||||||
axios.defaults.baseURL = basePath;
|
axios.defaults.baseURL = basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the cache from the meta tag if a server-rendered page injected
|
|
||||||
// one — saves a round trip on legacy templates that still embed it.
|
|
||||||
csrfToken = readMetaToken();
|
csrfToken = readMetaToken();
|
||||||
|
|
||||||
axios.interceptors.request.use(
|
axios.interceptors.request.use(
|
||||||
async (config) => {
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
config.headers = config.headers || {};
|
|
||||||
const method = (config.method || 'get').toUpperCase();
|
const method = (config.method || 'get').toUpperCase();
|
||||||
if (!SAFE_METHODS.has(method)) {
|
if (!SAFE_METHODS.has(method)) {
|
||||||
const token = await ensureCsrfToken();
|
const token = await ensureCsrfToken();
|
||||||
if (token) config.headers['X-CSRF-Token'] = token;
|
if (token) config.headers.set('X-CSRF-Token', token);
|
||||||
}
|
}
|
||||||
if (config.data instanceof FormData) {
|
if (config.data instanceof FormData) {
|
||||||
config.headers['Content-Type'] = 'multipart/form-data';
|
config.headers.set('Content-Type', 'multipart/form-data');
|
||||||
} else {
|
} else {
|
||||||
const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || '');
|
const declaredType = String(config.headers.get('Content-Type') || config.headers.get('content-type') || '');
|
||||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||||
if (config.data !== undefined && typeof config.data !== 'string') {
|
if (config.data !== undefined && typeof config.data !== 'string') {
|
||||||
config.data = JSON.stringify(config.data);
|
config.data = JSON.stringify(config.data);
|
||||||
|
|
@ -87,12 +84,12 @@ export function setupAxios() {
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error),
|
(error: unknown) => Promise.reject(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
axios.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
(response) => response,
|
(response: AxiosResponse) => response,
|
||||||
async (error) => {
|
async (error: AxiosError) => {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
if (!sessionExpired) {
|
if (!sessionExpired) {
|
||||||
|
|
@ -100,21 +97,19 @@ export function setupAxios() {
|
||||||
const basePath = window.X_UI_BASE_PATH || '/';
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
window.location.replace(basePath);
|
window.location.replace(basePath);
|
||||||
}
|
}
|
||||||
return new Promise(() => { });
|
return new Promise(() => {});
|
||||||
}
|
}
|
||||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
const cfg = error.config as CsrfAwareConfig | undefined;
|
||||||
const cfg = error.config;
|
|
||||||
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
||||||
csrfToken = null;
|
csrfToken = null;
|
||||||
cfg.__csrfRetried = true;
|
cfg.__csrfRetried = true;
|
||||||
const token = await ensureCsrfToken();
|
const token = await ensureCsrfToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
cfg.headers = cfg.headers || {};
|
cfg.headers.set('X-CSRF-Token', token);
|
||||||
cfg.headers['X-CSRF-Token'] = token;
|
const declaredType = String(cfg.headers.get('Content-Type') || cfg.headers.get('content-type') || '');
|
||||||
const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || '');
|
|
||||||
if (typeof cfg.data === 'string') {
|
if (typeof cfg.data === 'string') {
|
||||||
if (declaredType.toLowerCase().startsWith('application/json')) {
|
if (declaredType.toLowerCase().startsWith('application/json')) {
|
||||||
try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ }
|
try { cfg.data = JSON.parse(cfg.data); } catch {}
|
||||||
} else {
|
} else {
|
||||||
cfg.data = qs.parse(cfg.data);
|
cfg.data = qs.parse(cfg.data);
|
||||||
}
|
}
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
/**
|
|
||||||
* WebSocket client for real-time panel updates.
|
|
||||||
*
|
|
||||||
* Public API (kept stable for index.html / inbounds.html / xray.html):
|
|
||||||
* - connect() — open the connection (idempotent)
|
|
||||||
* - disconnect() — close and stop reconnecting
|
|
||||||
* - on(event, callback) — subscribe to event
|
|
||||||
* - off(event, callback) — unsubscribe
|
|
||||||
* - send(data) — send JSON to the server
|
|
||||||
* - isConnected — boolean, current state
|
|
||||||
* - reconnectAttempts — number, attempts since last success
|
|
||||||
* - maxReconnectAttempts — number, give-up threshold
|
|
||||||
*
|
|
||||||
* Built-in events:
|
|
||||||
* 'connected', 'disconnected', 'error', 'message',
|
|
||||||
* plus any server-emitted message type (status, traffic, client_stats, ...).
|
|
||||||
*/
|
|
||||||
export class WebSocketClient {
|
|
||||||
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
|
|
||||||
static #BASE_RECONNECT_MS = 1000;
|
|
||||||
static #MAX_RECONNECT_MS = 30_000;
|
|
||||||
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
|
|
||||||
// cadence rather than giving up forever — a panel that recovers an hour
|
|
||||||
// later should reconnect without a manual page reload.
|
|
||||||
static #SLOW_RETRY_MS = 60_000;
|
|
||||||
|
|
||||||
constructor(basePath = '') {
|
|
||||||
this.basePath = basePath;
|
|
||||||
this.maxReconnectAttempts = 10;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.isConnected = false;
|
|
||||||
|
|
||||||
this.ws = null;
|
|
||||||
this.shouldReconnect = true;
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
this.listeners = new Map(); // event → Set<callback>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the connection. Safe to call repeatedly — no-op if already
|
|
||||||
// open/connecting. Re-enables reconnects if previously disabled. Cancels
|
|
||||||
// any pending reconnect timer so an external connect() can't race a
|
|
||||||
// delayed retry into spawning a second socket.
|
|
||||||
connect() {
|
|
||||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.shouldReconnect = true;
|
|
||||||
this.#cancelReconnect();
|
|
||||||
this.#openSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the connection and stop any pending reconnect attempt. Resets the
|
|
||||||
// attempt counter so a future connect() starts fresh from the small backoff.
|
|
||||||
disconnect() {
|
|
||||||
this.shouldReconnect = false;
|
|
||||||
this.#cancelReconnect();
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
if (this.ws) {
|
|
||||||
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
this.isConnected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to an event. Re-subscribing the same callback is a no-op.
|
|
||||||
on(event, callback) {
|
|
||||||
if (typeof callback !== 'function') return;
|
|
||||||
let set = this.listeners.get(event);
|
|
||||||
if (!set) {
|
|
||||||
set = new Set();
|
|
||||||
this.listeners.set(event, set);
|
|
||||||
}
|
|
||||||
set.add(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe from an event.
|
|
||||||
off(event, callback) {
|
|
||||||
const set = this.listeners.get(event);
|
|
||||||
if (!set) return;
|
|
||||||
set.delete(callback);
|
|
||||||
if (set.size === 0) this.listeners.delete(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send JSON to the server. Drops silently if not connected — callers
|
|
||||||
// should rely on connect()/server pushes rather than client-initiated sends.
|
|
||||||
send(data) {
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───── internals ─────
|
|
||||||
|
|
||||||
#openSocket() {
|
|
||||||
const url = this.#buildUrl();
|
|
||||||
let socket;
|
|
||||||
try {
|
|
||||||
socket = new WebSocket(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('WebSocket: failed to construct connection', err);
|
|
||||||
this.#emit('error', err);
|
|
||||||
this.#scheduleReconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.ws = socket;
|
|
||||||
|
|
||||||
// Every handler must check `this.ws !== socket` first. A previous socket
|
|
||||||
// can still fire events (especially `close`) after we've moved on to a
|
|
||||||
// new one — e.g. connect() called while the old socket is in CLOSING
|
|
||||||
// state. Without the guard, a stale close would null out the freshly
|
|
||||||
// opened socket and silently break send().
|
|
||||||
socket.addEventListener('open', () => {
|
|
||||||
if (this.ws !== socket) return;
|
|
||||||
this.isConnected = true;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.#emit('connected');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => {
|
|
||||||
if (this.ws !== socket) return;
|
|
||||||
this.#onMessage(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addEventListener('error', (event) => {
|
|
||||||
if (this.ws !== socket) return;
|
|
||||||
// Browsers fire 'error' before 'close' on failure. We surface it for
|
|
||||||
// consumers (so polling fallbacks can engage) but don't log every blip
|
|
||||||
// — bad networks would flood the console otherwise.
|
|
||||||
this.#emit('error', event);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.addEventListener('close', () => {
|
|
||||||
if (this.ws !== socket) return;
|
|
||||||
this.isConnected = false;
|
|
||||||
this.ws = null;
|
|
||||||
this.#emit('disconnected');
|
|
||||||
if (this.shouldReconnect) this.#scheduleReconnect();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#buildUrl() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
// 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
|
|
||||||
// constructor throws a SyntaxError.
|
|
||||||
let basePath = this.basePath || '/';
|
|
||||||
if (!basePath.startsWith('/')) basePath = '/' + basePath;
|
|
||||||
if (!basePath.endsWith('/')) basePath += '/';
|
|
||||||
return `${protocol}//${window.location.host}${basePath}ws`;
|
|
||||||
}
|
|
||||||
|
|
||||||
#onMessage(event) {
|
|
||||||
const data = event.data;
|
|
||||||
// Reject oversized payloads up front. We compare actual UTF-8 byte
|
|
||||||
// length (via Blob.size) against the limit — string.length counts
|
|
||||||
// UTF-16 code units, which can undercount real bytes by up to 4× for
|
|
||||||
// payloads with non-ASCII characters and bypass the cap.
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
const byteLen = new Blob([data]).size;
|
|
||||||
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
|
||||||
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
|
||||||
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let message;
|
|
||||||
try {
|
|
||||||
message = JSON.parse(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('WebSocket: invalid JSON message', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
|
||||||
console.error('WebSocket: malformed message envelope');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#emit(message.type, message.payload, message.time);
|
|
||||||
this.#emit('message', message);
|
|
||||||
}
|
|
||||||
|
|
||||||
#emit(event, ...args) {
|
|
||||||
const set = this.listeners.get(event);
|
|
||||||
if (!set) return;
|
|
||||||
for (const callback of set) {
|
|
||||||
try {
|
|
||||||
callback(...args);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`WebSocket: handler for "${event}" threw`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#scheduleReconnect() {
|
|
||||||
if (!this.shouldReconnect) return;
|
|
||||||
this.#cancelReconnect();
|
|
||||||
|
|
||||||
let base;
|
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
||||||
this.reconnectAttempts += 1;
|
|
||||||
// Exponential backoff inside the active window.
|
|
||||||
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
|
||||||
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
|
||||||
} else {
|
|
||||||
// Active window exhausted — keep trying once a minute. The page-level
|
|
||||||
// polling fallback runs in parallel; this just brings WS back when the
|
|
||||||
// network recovers.
|
|
||||||
base = WebSocketClient.#SLOW_RETRY_MS;
|
|
||||||
}
|
|
||||||
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
|
|
||||||
const delay = base * (0.75 + Math.random() * 0.5);
|
|
||||||
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
// clearTimeout doesn't cancel a callback that has already fired but
|
|
||||||
// whose macrotask hasn't run yet — re-check shouldReconnect here so
|
|
||||||
// disconnect() called in that window can't be overridden.
|
|
||||||
if (!this.shouldReconnect) return;
|
|
||||||
this.#openSocket();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
#cancelReconnect() {
|
|
||||||
if (this.reconnectTimer !== null) {
|
|
||||||
clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
192
frontend/src/api/websocket.ts
Normal file
192
frontend/src/api/websocket.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
type WebSocketListener = (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
interface WebSocketMessage {
|
||||||
|
type: string;
|
||||||
|
payload?: unknown;
|
||||||
|
time?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebSocketClient {
|
||||||
|
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||||
|
static #BASE_RECONNECT_MS = 1000;
|
||||||
|
static #MAX_RECONNECT_MS = 30_000;
|
||||||
|
static #SLOW_RETRY_MS = 60_000;
|
||||||
|
|
||||||
|
basePath: string;
|
||||||
|
maxReconnectAttempts: number;
|
||||||
|
reconnectAttempts: number;
|
||||||
|
isConnected: boolean;
|
||||||
|
|
||||||
|
private ws: WebSocket | null;
|
||||||
|
private shouldReconnect: boolean;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
private listeners: Map<string, Set<WebSocketListener>>;
|
||||||
|
|
||||||
|
constructor(basePath = '') {
|
||||||
|
this.basePath = basePath;
|
||||||
|
this.maxReconnectAttempts = 10;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.isConnected = false;
|
||||||
|
|
||||||
|
this.ws = null;
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.listeners = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
this.#cancelReconnect();
|
||||||
|
this.#openSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.#cancelReconnect();
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
if (this.ws) {
|
||||||
|
try { this.ws.close(1000, 'client disconnect'); } catch {}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: string, callback: WebSocketListener): void {
|
||||||
|
if (typeof callback !== 'function') return;
|
||||||
|
let set = this.listeners.get(event);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.listeners.set(event, set);
|
||||||
|
}
|
||||||
|
set.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, callback: WebSocketListener): void {
|
||||||
|
const set = this.listeners.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
set.delete(callback);
|
||||||
|
if (set.size === 0) this.listeners.delete(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: unknown): void {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#openSocket(): void {
|
||||||
|
const url = this.#buildUrl();
|
||||||
|
let socket: WebSocket;
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebSocket: failed to construct connection', err);
|
||||||
|
this.#emit('error', err);
|
||||||
|
this.#scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ws = socket;
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
|
this.isConnected = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.#emit('connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
|
this.#onMessage(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('error', (event) => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
|
this.#emit('error', event);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
if (this.ws !== socket) return;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.ws = null;
|
||||||
|
this.#emit('disconnected');
|
||||||
|
if (this.shouldReconnect) this.#scheduleReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildUrl(): string {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
let basePath = this.basePath || '/';
|
||||||
|
if (!basePath.startsWith('/')) basePath = '/' + basePath;
|
||||||
|
if (!basePath.endsWith('/')) basePath += '/';
|
||||||
|
return `${protocol}//${window.location.host}${basePath}ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
#onMessage(event: MessageEvent): void {
|
||||||
|
const data = event.data;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
const byteLen = new Blob([data]).size;
|
||||||
|
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||||
|
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
||||||
|
try { this.ws?.close(1009, 'message too big'); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let message: unknown;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(typeof data === 'string' ? data : '');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebSocket: invalid JSON message', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!message || typeof message !== 'object' || typeof (message as { type?: unknown }).type !== 'string') {
|
||||||
|
console.error('WebSocket: malformed message envelope');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = message as WebSocketMessage;
|
||||||
|
this.#emit(msg.type, msg.payload, msg.time);
|
||||||
|
this.#emit('message', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#emit(event: string, ...args: unknown[]): void {
|
||||||
|
const set = this.listeners.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
for (const callback of set) {
|
||||||
|
try {
|
||||||
|
callback(...args);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`WebSocket: handler for "${event}" threw`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#scheduleReconnect(): void {
|
||||||
|
if (!this.shouldReconnect) return;
|
||||||
|
this.#cancelReconnect();
|
||||||
|
|
||||||
|
let base: number;
|
||||||
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.reconnectAttempts += 1;
|
||||||
|
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
||||||
|
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
||||||
|
} else {
|
||||||
|
base = WebSocketClient.#SLOW_RETRY_MS;
|
||||||
|
}
|
||||||
|
const delay = base * (0.75 + Math.random() * 0.5);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
if (!this.shouldReconnect) return;
|
||||||
|
this.#openSocket();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
#cancelReconnect(): void {
|
||||||
|
if (this.reconnectTimer !== null) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { WebSocketClient } from '@/api/websocket.js';
|
import { WebSocketClient } from '@/api/websocket';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
type Handler = (payload: unknown) => void;
|
type Handler = (payload: unknown) => void;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: rgba(0, 0, 0, 0.88);
|
color: var(--ant-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-brand {
|
.sider-brand {
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 14px 14px;
|
padding: 14px 14px;
|
||||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: rgba(0, 0, 0, 0.75);
|
color: var(--ant-color-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(0, 0, 0, 0.75);
|
color: var(--ant-color-text-secondary);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||||
|
|
@ -110,15 +110,14 @@
|
||||||
|
|
||||||
.sidebar-theme-cycle:hover,
|
.sidebar-theme-cycle:hover,
|
||||||
.sidebar-theme-cycle:focus-visible {
|
.sidebar-theme-cycle:focus-visible {
|
||||||
background-color: rgba(64, 150, 255, 0.1);
|
background-color: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||||
color: #4096ff;
|
color: var(--ant-color-primary);
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-theme-cycle svg {
|
.sidebar-theme-cycle .anticon {
|
||||||
width: 16px;
|
font-size: 16px;
|
||||||
height: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-header-actions {
|
.drawer-header-actions {
|
||||||
|
|
@ -151,7 +150,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-close {
|
.drawer-close {
|
||||||
|
|
@ -165,12 +164,12 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: rgba(0, 0, 0, 0.65);
|
color: var(--ant-color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-close:hover,
|
.drawer-close:hover,
|
||||||
.drawer-close:focus-visible {
|
.drawer-close:focus-visible {
|
||||||
background: rgba(128, 128, 128, 0.18);
|
background: var(--ant-color-fill-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-menu .ant-menu-item {
|
.drawer-menu .ant-menu-item {
|
||||||
|
|
@ -186,7 +185,7 @@
|
||||||
|
|
||||||
.drawer-utility {
|
.drawer-utility {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
border-top: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||||
|
|
@ -204,7 +203,7 @@
|
||||||
|
|
||||||
.sider-utility {
|
.sider-utility {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
border-top: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
@ -225,55 +224,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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-nav .ant-menu-item-selected,
|
||||||
.sider-utility .ant-menu-item-selected,
|
.sider-utility .ant-menu-item-selected,
|
||||||
.drawer-menu .ant-menu-item-selected {
|
.drawer-menu .ant-menu-item-selected {
|
||||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
background-color: color-mix(in srgb, var(--ant-color-primary) 20%, transparent) !important;
|
||||||
color: #4096ff !important;
|
color: var(--ant-color-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||||
|
|
@ -282,6 +237,6 @@ html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
.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,
|
.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 {
|
.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;
|
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent) !important;
|
||||||
color: #4096ff !important;
|
color: var(--ant-color-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ import {
|
||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
|
MoonFilled,
|
||||||
|
MoonOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
SunOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
|
@ -69,6 +72,7 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||||
onCycle: () => void;
|
onCycle: () => void;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
}) {
|
}) {
|
||||||
|
const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
id={id}
|
id={id}
|
||||||
|
|
@ -78,21 +82,7 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||||
title={ariaLabel}
|
title={ariaLabel}
|
||||||
onClick={onCycle}
|
onClick={onCycle}
|
||||||
>
|
>
|
||||||
{!isDark ? (
|
{icon}
|
||||||
<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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd'
|
||||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { RandomUtil } from '@/utils';
|
import { RandomUtil } from '@/utils';
|
||||||
import { Protocols } from '@/models/outbound.js';
|
import { Protocols } from '@/models/outbound';
|
||||||
|
|
||||||
interface StreamShape {
|
interface StreamShape {
|
||||||
network?: string;
|
network?: string;
|
||||||
|
|
@ -138,7 +138,7 @@ export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskF
|
||||||
<Divider style={{ margin: 0 }}>
|
<Divider style={{ margin: 0 }}>
|
||||||
TCP Mask {mIdx + 1}
|
TCP Mask {mIdx + 1}
|
||||||
<DeleteOutlined
|
<DeleteOutlined
|
||||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
className="danger-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
stream.delTcpMask(mIdx);
|
stream.delTcpMask(mIdx);
|
||||||
notify();
|
notify();
|
||||||
|
|
@ -238,7 +238,7 @@ export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskF
|
||||||
<Divider style={{ margin: 0 }}>
|
<Divider style={{ margin: 0 }}>
|
||||||
UDP Mask {mIdx + 1}
|
UDP Mask {mIdx + 1}
|
||||||
<DeleteOutlined
|
<DeleteOutlined
|
||||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
className="danger-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
stream.delUdpMask(mIdx);
|
stream.delUdpMask(mIdx);
|
||||||
notify();
|
notify();
|
||||||
|
|
@ -403,7 +403,7 @@ function HeaderCustomGroups({
|
||||||
<Divider style={{ margin: 0 }}>
|
<Divider style={{ margin: 0 }}>
|
||||||
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
|
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
|
||||||
<DeleteOutlined
|
<DeleteOutlined
|
||||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
className="danger-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
(settings[groupKey] as ItemRow[][]).splice(gi, 1);
|
(settings[groupKey] as ItemRow[][]).splice(gi, 1);
|
||||||
onChange();
|
onChange();
|
||||||
|
|
@ -445,7 +445,7 @@ function UdpHeaderCustom({ mask, onChange }: { mask: MaskRow; onChange: () => vo
|
||||||
<Divider style={{ margin: 0 }}>
|
<Divider style={{ margin: 0 }}>
|
||||||
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
|
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
|
||||||
<DeleteOutlined
|
<DeleteOutlined
|
||||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
className="danger-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
(settings[groupKey] as ItemRow[]).splice(ci, 1);
|
(settings[groupKey] as ItemRow[]).splice(ci, 1);
|
||||||
onChange();
|
onChange();
|
||||||
|
|
@ -493,7 +493,7 @@ function NoiseItems({ mask, onChange }: { mask: MaskRow; onChange: () => void })
|
||||||
<Divider style={{ margin: 0 }}>
|
<Divider style={{ margin: 0 }}>
|
||||||
Noise {ni + 1}
|
Noise {ni + 1}
|
||||||
<DeleteOutlined
|
<DeleteOutlined
|
||||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
className="danger-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
(settings.noise as ItemRow[]).splice(ni, 1);
|
(settings.noise as ItemRow[]).splice(ni, 1);
|
||||||
onChange();
|
onChange();
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,15 @@
|
||||||
height: 32px;
|
height: 32px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
background-color: var(--ant-color-fill-tertiary);
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid var(--ant-color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
color: rgba(0, 0, 0, 0.88);
|
color: var(--ant-color-text);
|
||||||
white-space: nowrap;
|
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) {
|
.ant-space-compact > .input-addon:not(:first-child) {
|
||||||
margin-inline-start: -1px;
|
margin-inline-start: -1px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
.json-editor-host {
|
.json-editor-host {
|
||||||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
border: 1px solid var(--ant-color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--ant-color-bg-container, #fff);
|
background: var(--ant-color-bg-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor-host .cm-editor,
|
.json-editor-host .cm-editor,
|
||||||
|
|
@ -11,16 +11,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-editor-host:focus-within {
|
.json-editor-host:focus-within {
|
||||||
border-color: var(--ant-color-primary, #1677ff);
|
border-color: var(--ant-color-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .json-editor-host {
|
|
||||||
border-color: #3a3a3c;
|
|
||||||
background: #1e1e1e;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="ultra-dark"] .json-editor-host {
|
|
||||||
border-color: #1f1f1f;
|
|
||||||
background: #0a0a0a;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,13 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-list-item:last-child {
|
.setting-list-item:last-child {
|
||||||
border-bottom: 0;
|
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 {
|
.setting-list-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -22,22 +17,12 @@ html[data-theme='ultra-dark'] .setting-list-item {
|
||||||
|
|
||||||
.setting-list-title {
|
.setting-list-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(0, 0, 0, 0.88);
|
color: var(--ant-color-text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-list-description {
|
.setting-list-description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(0, 0, 0, 0.45);
|
color: var(--ant-color-text-tertiary);
|
||||||
line-height: 1.5715;
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,43 +2,3 @@
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
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));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,29 @@
|
||||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
import { useId, useMemo } from 'react';
|
||||||
import type { MouseEvent } from 'react';
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
import './Sparkline.css';
|
import './Sparkline.css';
|
||||||
|
|
||||||
interface SparklineProps {
|
interface SparklineProps {
|
||||||
data: number[];
|
data: number[];
|
||||||
labels?: (string | number)[];
|
labels?: (string | number)[];
|
||||||
vbWidth?: number;
|
|
||||||
height?: number;
|
height?: number;
|
||||||
stroke?: string;
|
stroke?: string;
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
maxPoints?: number;
|
maxPoints?: number;
|
||||||
showGrid?: boolean;
|
showGrid?: boolean;
|
||||||
gridColor?: string;
|
|
||||||
fillOpacity?: number;
|
fillOpacity?: number;
|
||||||
showMarker?: boolean;
|
showMarker?: boolean;
|
||||||
markerRadius?: number;
|
markerRadius?: number;
|
||||||
showAxes?: boolean;
|
showAxes?: boolean;
|
||||||
yTickStep?: number;
|
yTickStep?: number;
|
||||||
tickCountX?: number;
|
tickCountX?: number;
|
||||||
paddingLeft?: number;
|
|
||||||
paddingRight?: number;
|
|
||||||
paddingTop?: number;
|
|
||||||
paddingBottom?: number;
|
|
||||||
showTooltip?: boolean;
|
showTooltip?: boolean;
|
||||||
valueMin?: number;
|
valueMin?: number;
|
||||||
valueMax?: number | null;
|
valueMax?: number | null;
|
||||||
|
|
@ -29,340 +31,136 @@ interface SparklineProps {
|
||||||
tooltipFormatter?: ((v: number) => string) | null;
|
tooltipFormatter?: ((v: number) => string) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChartPoint {
|
||||||
|
index: number;
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Sparkline({
|
export default function Sparkline({
|
||||||
data,
|
data,
|
||||||
labels = [],
|
labels = [],
|
||||||
vbWidth = 320,
|
|
||||||
height = 80,
|
height = 80,
|
||||||
stroke = '#008771',
|
stroke = '#008771',
|
||||||
strokeWidth = 2,
|
strokeWidth = 2,
|
||||||
maxPoints = 120,
|
maxPoints = 120,
|
||||||
showGrid = true,
|
showGrid = true,
|
||||||
gridColor = 'rgba(0,0,0,0.08)',
|
|
||||||
fillOpacity = 0.22,
|
fillOpacity = 0.22,
|
||||||
showMarker = true,
|
showMarker = true,
|
||||||
markerRadius = 3,
|
markerRadius = 3,
|
||||||
showAxes = false,
|
showAxes = false,
|
||||||
yTickStep = 25,
|
yTickStep = 25,
|
||||||
tickCountX = 4,
|
tickCountX = 4,
|
||||||
paddingLeft = 56,
|
|
||||||
paddingRight = 6,
|
|
||||||
paddingTop = 6,
|
|
||||||
paddingBottom = 20,
|
|
||||||
showTooltip = false,
|
showTooltip = false,
|
||||||
valueMin = 0,
|
valueMin = 0,
|
||||||
valueMax = 100,
|
valueMax = 100,
|
||||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||||
tooltipFormatter = null,
|
tooltipFormatter = null,
|
||||||
}: SparklineProps) {
|
}: SparklineProps) {
|
||||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
||||||
const [measuredWidth, setMeasuredWidth] = useState(0);
|
|
||||||
const [hoverIdx, setHoverIdx] = useState(-1);
|
|
||||||
|
|
||||||
const reactId = useId();
|
const reactId = useId();
|
||||||
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
|
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
|
||||||
const gradId = `spkGrad-${safeId}`;
|
const gradId = `spkGrad-${safeId}`;
|
||||||
const shadowId = `spkShadow-${safeId}`;
|
|
||||||
const glowId = `spkGlow-${safeId}`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
const points = useMemo<ChartPoint[]>(() => {
|
||||||
const el = svgRef.current;
|
const n = Math.min(data.length, maxPoints);
|
||||||
if (!el) return;
|
if (n === 0) return [];
|
||||||
const measure = () => {
|
const sliceStart = data.length - n;
|
||||||
const w = el.getBoundingClientRect?.().width || 0;
|
const labelStart = Math.max(0, labels.length - n);
|
||||||
if (w > 0) setMeasuredWidth(Math.round(w));
|
return data.slice(sliceStart).map((value, i) => ({
|
||||||
};
|
index: i,
|
||||||
measure();
|
value: Number(value) || 0,
|
||||||
if (typeof ResizeObserver !== 'undefined') {
|
label: String(labels[labelStart + i] ?? i + 1),
|
||||||
const ro = new ResizeObserver(measure);
|
}));
|
||||||
ro.observe(el);
|
}, [data, labels, maxPoints]);
|
||||||
return () => ro.disconnect();
|
|
||||||
|
const yDomain = useMemo<[number, number]>(() => {
|
||||||
|
if (valueMax != null) return [valueMin, valueMax];
|
||||||
|
let max = valueMin;
|
||||||
|
for (const p of points) {
|
||||||
|
if (Number.isFinite(p.value) && p.value > max) max = p.value;
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', measure);
|
if (max <= valueMin) max = valueMin + 1;
|
||||||
return () => window.removeEventListener('resize', measure);
|
return [valueMin, max * 1.1];
|
||||||
}, []);
|
}, [points, valueMin, valueMax]);
|
||||||
|
|
||||||
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(() => {
|
const yTicks = useMemo(() => {
|
||||||
if (!showAxes) return [];
|
if (!showAxes) return undefined;
|
||||||
const { min, max } = yDomain;
|
const [min, max] = yDomain;
|
||||||
const out: { y: number; label: string }[] = [];
|
|
||||||
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
||||||
for (let p = min; p <= max; p += yTickStep) {
|
const out: number[] = [];
|
||||||
out.push({ y: project(p), label: yFormatter(p) });
|
for (let v = min; v <= max; v += yTickStep) out.push(v);
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
const ticks = 5;
|
const n = 5;
|
||||||
for (let i = 0; i < ticks; i++) {
|
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
|
||||||
const v = min + ((max - min) * i) / (ticks - 1);
|
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
|
||||||
out.push({ y: project(v), label: yFormatter(v) });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]);
|
|
||||||
|
|
||||||
const xTicks = useMemo(() => {
|
const xTickIndexes = useMemo(() => {
|
||||||
if (!showAxes) return [];
|
if (!showAxes || points.length === 0) return undefined;
|
||||||
if (nPoints === 0) return [];
|
|
||||||
const m = Math.max(2, tickCountX);
|
const m = Math.max(2, tickCountX);
|
||||||
const w = drawWidth;
|
return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
|
||||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
}, [showAxes, tickCountX, points.length]);
|
||||||
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(
|
const fmtTooltip = tooltipFormatter ?? yFormatter;
|
||||||
(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 (
|
return (
|
||||||
<svg
|
<ResponsiveContainer width="100%" height={height} className="sparkline-svg">
|
||||||
ref={svgRef}
|
<AreaChart data={points} margin={{ top: 6, right: 6, bottom: showAxes ? 14 : 4, left: showAxes ? 4 : 4 }}>
|
||||||
width="100%"
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${effectiveVbWidth} ${height}`}
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
className="sparkline-svg"
|
|
||||||
onMouseMove={onMouseMove}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
<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="0%" stopColor={stroke} stopOpacity={fillOpacity} />
|
||||||
<stop offset="50%" stopColor={stroke} stopOpacity={fillOpacity * 0.7} />
|
|
||||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||||
</linearGradient>
|
</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>
|
</defs>
|
||||||
|
|
||||||
{showGrid && (
|
{showGrid && (
|
||||||
<g>
|
<CartesianGrid stroke="var(--ant-color-border-secondary)" strokeDasharray="2 4" vertical={false} />
|
||||||
{gridLines.map((g, i) => (
|
)}
|
||||||
<line
|
<XAxis
|
||||||
key={i}
|
dataKey="label"
|
||||||
x1={g.x1}
|
hide={!showAxes}
|
||||||
y1={g.y1}
|
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
|
||||||
x2={g.x2}
|
axisLine={false}
|
||||||
y2={g.y2}
|
tickLine={false}
|
||||||
stroke={gridColor}
|
interval={0}
|
||||||
strokeWidth={1}
|
ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
|
||||||
strokeDasharray="3 5"
|
/>
|
||||||
className="cpu-grid-line"
|
<YAxis
|
||||||
|
domain={yDomain}
|
||||||
|
hide={!showAxes}
|
||||||
|
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={yFormatter}
|
||||||
|
ticks={yTicks}
|
||||||
|
width={48}
|
||||||
|
/>
|
||||||
|
{showTooltip && (
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--ant-color-bg-elevated)',
|
||||||
|
border: '1px solid var(--ant-color-border-secondary)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '4px 8px',
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 2 }}
|
||||||
|
itemStyle={{ color: 'var(--ant-color-text)', padding: 0 }}
|
||||||
|
formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
|
||||||
|
separator=""
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
)}
|
)}
|
||||||
|
<Area
|
||||||
{showAxes && (
|
type="monotone"
|
||||||
<g>
|
dataKey="value"
|
||||||
{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}
|
stroke={stroke}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
strokeLinecap="round"
|
fill={`url(#${gradId})`}
|
||||||
strokeLinejoin="round"
|
dot={false}
|
||||||
filter={`url(#${shadowId})`}
|
activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
{showMarker && lastPoint && (
|
</AreaChart>
|
||||||
<>
|
</ResponsiveContainer>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { createRoot } from 'react-dom/client';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
|
|
||||||
import { setupAxios } from '@/api/axios-init.js';
|
import { setupAxios } from '@/api/axios-init';
|
||||||
import { applyDocumentTitle } from '@/utils';
|
import { applyDocumentTitle } from '@/utils';
|
||||||
import { readyI18n } from '@/i18n/react';
|
import { readyI18n } from '@/i18n/react';
|
||||||
import { ThemeProvider } from '@/hooks/useTheme';
|
import { ThemeProvider } from '@/hooks/useTheme';
|
||||||
|
|
|
||||||
22
frontend/src/env.d.ts
vendored
22
frontend/src/env.d.ts
vendored
|
|
@ -28,6 +28,28 @@ interface Window {
|
||||||
__SUB_PAGE_DATA__?: SubPageData;
|
__SUB_PAGE_DATA__?: SubPageData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'qs' {
|
||||||
|
interface StringifyOptions {
|
||||||
|
arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma';
|
||||||
|
encode?: boolean;
|
||||||
|
encoder?: (str: unknown, defaultEncoder: (s: unknown) => string, charset: string, type: 'key' | 'value') => string;
|
||||||
|
allowDots?: boolean;
|
||||||
|
skipNulls?: boolean;
|
||||||
|
addQueryPrefix?: boolean;
|
||||||
|
}
|
||||||
|
interface ParseOptions {
|
||||||
|
depth?: number;
|
||||||
|
arrayLimit?: number;
|
||||||
|
allowDots?: boolean;
|
||||||
|
parseArrays?: boolean;
|
||||||
|
ignoreQueryPrefix?: boolean;
|
||||||
|
}
|
||||||
|
export function stringify(obj: unknown, options?: StringifyOptions): string;
|
||||||
|
export function parse(str: string, options?: ParseOptions): Record<string, unknown>;
|
||||||
|
const qs: { stringify: typeof stringify; parse: typeof parse };
|
||||||
|
export default qs;
|
||||||
|
}
|
||||||
|
|
||||||
declare module 'persian-calendar-suite' {
|
declare module 'persian-calendar-suite' {
|
||||||
import type { ComponentType, ReactNode } from 'react';
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,25 @@ const ULTRA_DARK_MENU_TOKENS = {
|
||||||
darkSubMenuItemBg: '#000',
|
darkSubMenuItemBg: '#000',
|
||||||
darkPopupBg: '#101013',
|
darkPopupBg: '#101013',
|
||||||
};
|
};
|
||||||
|
const DARK_CARD_TOKENS = {
|
||||||
|
colorBorderSecondary: 'rgba(255, 255, 255, 0.06)',
|
||||||
|
};
|
||||||
|
const ULTRA_DARK_CARD_TOKENS = {
|
||||||
|
colorBorderSecondary: 'rgba(255, 255, 255, 0.04)',
|
||||||
|
};
|
||||||
|
const STATISTIC_TOKENS = {
|
||||||
|
contentFontSize: 17,
|
||||||
|
titleFontSize: 11,
|
||||||
|
};
|
||||||
|
|
||||||
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
|
||||||
if (!isDark) {
|
if (!isDark) {
|
||||||
return { algorithm: antdTheme.defaultAlgorithm };
|
return {
|
||||||
|
algorithm: antdTheme.defaultAlgorithm,
|
||||||
|
components: {
|
||||||
|
Statistic: STATISTIC_TOKENS,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
algorithm: antdTheme.darkAlgorithm,
|
algorithm: antdTheme.darkAlgorithm,
|
||||||
|
|
@ -79,6 +94,8 @@ export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeCo
|
||||||
components: {
|
components: {
|
||||||
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||||
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||||
|
Card: isUltra ? ULTRA_DARK_CARD_TOKENS : DARK_CARD_TOKENS,
|
||||||
|
Statistic: STATISTIC_TOKENS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { WebSocketClient } from '@/api/websocket.js';
|
import { WebSocketClient } from '@/api/websocket';
|
||||||
|
|
||||||
type Handler = (payload: unknown) => void;
|
type Handler = (payload: unknown) => void;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@ import { createRoot } from 'react-dom/client';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import 'antd/dist/reset.css';
|
import 'antd/dist/reset.css';
|
||||||
|
import '@/styles/utils.css';
|
||||||
|
import '@/styles/page-shell.css';
|
||||||
|
import '@/styles/page-cards.css';
|
||||||
|
|
||||||
import { setupAxios } from '@/api/axios-init.js';
|
import { setupAxios } from '@/api/axios-init';
|
||||||
import { readyI18n } from '@/i18n/react';
|
import { readyI18n } from '@/i18n/react';
|
||||||
import { ThemeProvider } from '@/hooks/useTheme';
|
import { ThemeProvider } from '@/hooks/useTheme';
|
||||||
import { QueryProvider } from '@/api/QueryProvider';
|
import { QueryProvider } from '@/api/QueryProvider';
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,94 @@
|
||||||
import dayjs from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||||
import { Inbound, Protocols } from './inbound.js';
|
import { Inbound, Protocols } from './inbound';
|
||||||
|
|
||||||
export function coerceInboundJsonField(value) {
|
export type RawJsonField = string | Record<string, unknown> | unknown[];
|
||||||
|
|
||||||
|
export interface ClientStats {
|
||||||
|
email: string;
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
total: number;
|
||||||
|
expiryTime: number;
|
||||||
|
enable?: boolean;
|
||||||
|
inboundId?: number;
|
||||||
|
reset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FallbackParentRef {
|
||||||
|
masterId: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DBInboundInit = Partial<{
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
total: number;
|
||||||
|
remark: string;
|
||||||
|
enable: boolean;
|
||||||
|
expiryTime: number;
|
||||||
|
trafficReset: string;
|
||||||
|
lastTrafficResetTime: number;
|
||||||
|
listen: string;
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
settings: RawJsonField;
|
||||||
|
streamSettings: RawJsonField;
|
||||||
|
tag: string;
|
||||||
|
sniffing: RawJsonField;
|
||||||
|
clientStats: ClientStats[];
|
||||||
|
nodeId: number | null;
|
||||||
|
fallbackParent: FallbackParentRef | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function coerceInboundJsonField(value: unknown): Record<string, unknown> {
|
||||||
if (value == null) return {};
|
if (value == null) return {};
|
||||||
if (typeof value === 'object') return value;
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
if (typeof value !== 'string') return {};
|
if (typeof value !== 'string') return {};
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed === '') return {};
|
if (trimmed === '') return {};
|
||||||
try {
|
try {
|
||||||
return JSON.parse(trimmed);
|
const parsed = JSON.parse(trimmed);
|
||||||
} catch (_e) {
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DBInbound {
|
export class DBInbound {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
total: number;
|
||||||
|
remark: string;
|
||||||
|
enable: boolean;
|
||||||
|
expiryTime: number;
|
||||||
|
trafficReset: string;
|
||||||
|
lastTrafficResetTime: number;
|
||||||
|
|
||||||
constructor(data) {
|
listen: string;
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
settings: RawJsonField;
|
||||||
|
streamSettings: RawJsonField;
|
||||||
|
tag: string;
|
||||||
|
sniffing: RawJsonField;
|
||||||
|
clientStats: ClientStats[];
|
||||||
|
nodeId: number | null;
|
||||||
|
fallbackParent: FallbackParentRef | null;
|
||||||
|
|
||||||
|
private _cachedInbound: Inbound | null = null;
|
||||||
|
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
||||||
|
|
||||||
|
constructor(data?: DBInboundInit) {
|
||||||
this.id = 0;
|
this.id = 0;
|
||||||
this.userId = 0;
|
this.userId = 0;
|
||||||
this.up = 0;
|
this.up = 0;
|
||||||
|
|
@ -36,12 +107,8 @@ export class DBInbound {
|
||||||
this.streamSettings = "";
|
this.streamSettings = "";
|
||||||
this.tag = "";
|
this.tag = "";
|
||||||
this.sniffing = "";
|
this.sniffing = "";
|
||||||
this.clientStats = ""
|
this.clientStats = [];
|
||||||
// Optional FK to web/runtime registered Node. null/undefined =
|
|
||||||
// local panel; otherwise the inbound lives on the named node.
|
|
||||||
this.nodeId = null;
|
this.nodeId = null;
|
||||||
// Populated by the API when this inbound is a fallback child of
|
|
||||||
// a VLESS/Trojan TCP-TLS master. Shape: { masterId, path }.
|
|
||||||
this.fallbackParent = null;
|
this.fallbackParent = null;
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -49,11 +116,11 @@ export class DBInbound {
|
||||||
ObjectUtil.cloneProps(this, data);
|
ObjectUtil.cloneProps(this, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalGB() {
|
get totalGB(): number {
|
||||||
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
|
return NumberFormatter.toFixed(this.total / SizeFormatter.ONE_GB, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
set totalGB(gb) {
|
set totalGB(gb: number) {
|
||||||
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
this.total = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +156,7 @@ export class DBInbound {
|
||||||
return this.protocol === Protocols.HYSTERIA;
|
return this.protocol === Protocols.HYSTERIA;
|
||||||
}
|
}
|
||||||
|
|
||||||
get address() {
|
get address(): string {
|
||||||
let address = location.hostname;
|
let address = location.hostname;
|
||||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||||
address = this.listen;
|
address = this.listen;
|
||||||
|
|
@ -97,14 +164,14 @@ export class DBInbound {
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
|
|
||||||
get _expiryTime() {
|
get _expiryTime(): Dayjs | null {
|
||||||
if (this.expiryTime === 0) {
|
if (this.expiryTime === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return dayjs(this.expiryTime);
|
return dayjs(this.expiryTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
set _expiryTime(t) {
|
set _expiryTime(t: Dayjs | null | undefined) {
|
||||||
if (t == null) {
|
if (t == null) {
|
||||||
this.expiryTime = 0;
|
this.expiryTime = 0;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -112,16 +179,16 @@ export class DBInbound {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get isExpiry() {
|
get isExpiry(): boolean {
|
||||||
return this.expiryTime < new Date().getTime();
|
return this.expiryTime < new Date().getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateCache() {
|
invalidateCache(): void {
|
||||||
this._cachedInbound = null;
|
this._cachedInbound = null;
|
||||||
this._clientStatsMap = null;
|
this._clientStatsMap = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
toInbound() {
|
toInbound(): Inbound {
|
||||||
if (this._cachedInbound) {
|
if (this._cachedInbound) {
|
||||||
return this._cachedInbound;
|
return this._cachedInbound;
|
||||||
}
|
}
|
||||||
|
|
@ -145,19 +212,21 @@ export class DBInbound {
|
||||||
return this._cachedInbound;
|
return this._cachedInbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientStats(email) {
|
getClientStats(email: string): ClientStats | undefined {
|
||||||
if (!this._clientStatsMap) {
|
if (!this._clientStatsMap) {
|
||||||
this._clientStatsMap = new Map();
|
this._clientStatsMap = new Map();
|
||||||
if (this.clientStats && Array.isArray(this.clientStats)) {
|
if (Array.isArray(this.clientStats)) {
|
||||||
for (const stats of this.clientStats) {
|
for (const stats of this.clientStats) {
|
||||||
|
if (stats && stats.email) {
|
||||||
this._clientStatsMap.set(stats.email, stats);
|
this._clientStatsMap.set(stats.email, stats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this._clientStatsMap.get(email);
|
return this._clientStatsMap.get(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
isMultiUser() {
|
isMultiUser(): boolean {
|
||||||
switch (this.protocol) {
|
switch (this.protocol) {
|
||||||
case Protocols.VMESS:
|
case Protocols.VMESS:
|
||||||
case Protocols.VLESS:
|
case Protocols.VLESS:
|
||||||
|
|
@ -171,7 +240,7 @@ export class DBInbound {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasLink() {
|
hasLink(): boolean {
|
||||||
switch (this.protocol) {
|
switch (this.protocol) {
|
||||||
case Protocols.VMESS:
|
case Protocols.VMESS:
|
||||||
case Protocols.VLESS:
|
case Protocols.VLESS:
|
||||||
|
|
@ -184,7 +253,7 @@ export class DBInbound {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
genInboundLinks(remarkModel, hostOverride = '') {
|
genInboundLinks(remarkModel: string, hostOverride: string = ''): string {
|
||||||
const inbound = this.toInbound();
|
const inbound = this.toInbound();
|
||||||
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
|
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
||||||
// List of popular services for VLESS Reality Target/SNI randomization
|
|
||||||
export const REALITY_TARGETS = [
|
|
||||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
|
||||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
|
||||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
|
||||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
|
||||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
|
||||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
|
||||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a random Reality target configuration from the predefined list
|
|
||||||
* @returns {Object} Object with target and sni properties
|
|
||||||
*/
|
|
||||||
export function getRandomRealityTarget() {
|
|
||||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
|
||||||
const selected = REALITY_TARGETS[randomIndex];
|
|
||||||
// Return a copy to avoid reference issues
|
|
||||||
return {
|
|
||||||
target: selected.target,
|
|
||||||
sni: selected.sni
|
|
||||||
};
|
|
||||||
}
|
|
||||||
23
frontend/src/models/reality-targets.ts
Normal file
23
frontend/src/models/reality-targets.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
export interface RealityTarget {
|
||||||
|
target: string;
|
||||||
|
sni: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REALITY_TARGETS: readonly RealityTarget[] = [
|
||||||
|
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||||
|
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||||
|
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||||
|
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||||
|
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||||
|
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||||
|
{ target: 'www.sony.com:443', sni: 'www.sony.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getRandomRealityTarget(): RealityTarget {
|
||||||
|
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||||
|
const selected = REALITY_TARGETS[randomIndex];
|
||||||
|
return {
|
||||||
|
target: selected.target,
|
||||||
|
sni: selected.sni,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,4 @@
|
||||||
.api-docs-page {
|
|
||||||
--bg-page: #e6e8ec;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-docs-page.is-dark {
|
.api-docs-page.is-dark {
|
||||||
--bg-page: #1a1b1f;
|
|
||||||
--bg-card: #23252b;
|
|
||||||
--sw-bg: #1f2026;
|
--sw-bg: #1f2026;
|
||||||
--sw-bg-soft: #25272e;
|
--sw-bg-soft: #25272e;
|
||||||
--sw-bg-input: #15161a;
|
--sw-bg-input: #15161a;
|
||||||
|
|
@ -22,8 +13,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-docs-page.is-dark.is-ultra {
|
.api-docs-page.is-dark.is-ultra {
|
||||||
--bg-page: #000;
|
|
||||||
--bg-card: #101013;
|
|
||||||
--sw-bg: #0a0a0d;
|
--sw-bg: #0a0a0d;
|
||||||
--sw-bg-soft: #131316;
|
--sw-bg-soft: #131316;
|
||||||
--sw-bg-input: #050507;
|
--sw-bg-input: #050507;
|
||||||
|
|
@ -51,7 +40,7 @@
|
||||||
.api-docs-page .docs-wrapper {
|
.api-docs-page .docs-wrapper {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'swagger-ui-react/swagger-ui.css';
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import AppSidebar from '@/components/AppSidebar';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import '@/styles/page-cards.css';
|
|
||||||
import './ApiDocsPage.css';
|
import './ApiDocsPage.css';
|
||||||
|
|
||||||
const basePath = window.X_UI_BASE_PATH || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
.random-icon {
|
|
||||||
margin-left: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--ant-color-primary, #1677ff);
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||||
import { TLS_FLOW_CONTROL } from '@/models/inbound';
|
import { TLS_FLOW_CONTROL } from '@/models/inbound';
|
||||||
import DateTimePicker from '@/components/DateTimePicker';
|
import DateTimePicker from '@/components/DateTimePicker';
|
||||||
import type { InboundOption } from '@/hooks/useClients';
|
import type { InboundOption } from '@/hooks/useClients';
|
||||||
import './ClientBulkAddModal.css';
|
|
||||||
|
|
||||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-panel {
|
.link-panel {
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
border: 1px solid var(--ant-color-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
@ -62,37 +62,25 @@
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--ant-color-fill-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .link-panel-text {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-panel-anchor {
|
.link-panel-anchor {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--ant-color-fill-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--ant-color-primary, #1677ff);
|
color: var(--ant-color-primary);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-color: rgba(22, 119, 255, 0.4);
|
text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 40%, transparent);
|
||||||
transition: background 120ms ease, text-decoration-color 120ms ease;
|
transition: background 120ms ease, text-decoration-color 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-panel-anchor:hover {
|
.link-panel-anchor:hover {
|
||||||
background: rgba(22, 119, 255, 0.08);
|
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||||
text-decoration-color: var(--ant-color-primary, #1677ff);
|
text-decoration-color: var(--ant-color-primary);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .link-panel-anchor {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .link-panel-anchor:hover {
|
|
||||||
background: rgba(22, 119, 255, 0.16);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,6 @@
|
||||||
.clients-page {
|
|
||||||
--bg-page: #e6e8ec;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clients-page.is-dark {
|
|
||||||
--bg-page: #1a1b1f;
|
|
||||||
--bg-card: #23252b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clients-page.is-dark.is-ultra {
|
|
||||||
--bg-page: #000;
|
|
||||||
--bg-card: #101013;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clients-page .ant-layout,
|
|
||||||
.clients-page .ant-layout-content {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clients-page .content-shell {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clients-page .content-area {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.clients-page .content-area {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.clients-page .ant-pagination-options-size-changer,
|
.clients-page .ant-pagination-options-size-changer,
|
||||||
.clients-page .ant-pagination-options-size-changer .ant-select-selector {
|
.clients-page .ant-pagination-options-size-changer .ant-select-selector {
|
||||||
min-width: 100px !important;
|
min-width: 100px;
|
||||||
}
|
|
||||||
|
|
||||||
.clients-page .loading-spacer {
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clients-page .summary-card {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.clients-page .summary-card {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-email-list {
|
.client-email-list {
|
||||||
|
|
@ -92,11 +42,11 @@
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot-green { background: #52c41a; }
|
.dot-green { background: var(--ant-color-success); }
|
||||||
.dot-blue { background: #1677ff; }
|
.dot-blue { background: var(--ant-color-primary); }
|
||||||
.dot-red { background: #ff4d4f; }
|
.dot-red { background: var(--ant-color-error); }
|
||||||
.dot-orange { background: #fa8c16; }
|
.dot-orange { background: var(--ant-color-warning); }
|
||||||
.dot-gray { background: rgba(128, 128, 128, 0.6); }
|
.dot-gray { background: var(--ant-color-text-quaternary); }
|
||||||
|
|
||||||
.status-tag {
|
.status-tag {
|
||||||
margin: 0 0 0 4px;
|
margin: 0 0 0 4px;
|
||||||
|
|
@ -154,32 +104,27 @@
|
||||||
|
|
||||||
.card-pagination .ant-pagination-options-size-changer,
|
.card-pagination .ant-pagination-options-size-changer,
|
||||||
.card-pagination .ant-pagination-options-size-changer .ant-select-selector {
|
.card-pagination .ant-pagination-options-size-changer .ant-select-selector {
|
||||||
min-width: 88px !important;
|
min-width: 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-count {
|
.bulk-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: rgba(22, 119, 255, 0.12);
|
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||||
color: var(--ant-color-primary, #1677ff);
|
color: var(--ant-color-primary);
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-card {
|
.client-card {
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--ant-color-fill-quaternary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-card.is-selected {
|
.client-card.is-selected {
|
||||||
border-color: var(--ant-color-primary, #1677ff);
|
border-color: var(--ant-color-primary);
|
||||||
background: rgba(22, 119, 255, 0.06);
|
background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .client-card {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-head {
|
.card-head {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
Statistic,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
|
@ -49,7 +50,6 @@ import { useClients } from '@/hooks/useClients';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||||
import AppSidebar from '@/components/AppSidebar';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import CustomStatistic from '@/components/CustomStatistic';
|
|
||||||
import { IntlUtil, SizeFormatter } from '@/utils';
|
import { IntlUtil, SizeFormatter } from '@/utils';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import LazyMount from '@/components/LazyMount';
|
import LazyMount from '@/components/LazyMount';
|
||||||
|
|
@ -58,7 +58,6 @@ const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
|
||||||
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
||||||
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
||||||
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
||||||
import '@/styles/page-cards.css';
|
|
||||||
import './ClientsPage.css';
|
import './ClientsPage.css';
|
||||||
|
|
||||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||||
|
|
@ -216,13 +215,12 @@ export default function ClientsPage() {
|
||||||
return 'active';
|
return 'active';
|
||||||
}, [expireDiff, trafficDiff]);
|
}, [expireDiff, trafficDiff]);
|
||||||
|
|
||||||
function bucketBadgeColor(bucket: Bucket | null): string {
|
function bucketBadgeStatus(bucket: Bucket | null): 'success' | 'warning' | 'error' | 'default' {
|
||||||
switch (bucket) {
|
switch (bucket) {
|
||||||
case 'depleted': return '#ff4d4f';
|
case 'depleted': return 'error';
|
||||||
case 'expiring': return '#fa8c16';
|
case 'expiring': return 'warning';
|
||||||
case 'deactive': return 'rgba(128,128,128,0.6)';
|
case 'active': return 'success';
|
||||||
case 'active': return '#52c41a';
|
default: return 'default';
|
||||||
default: return 'rgba(128,128,128,0.6)';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,7 +622,7 @@ export default function ClientsPage() {
|
||||||
<Card size="small" hoverable className="summary-card">
|
<Card size="small" hoverable className="summary-card">
|
||||||
<Row gutter={[16, 12]}>
|
<Row gutter={[16, 12]}>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<CustomStatistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
|
<Statistic title={t('clients')} value={String(summary.total)} prefix={<TeamOutlined />} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<Popover
|
||||||
|
|
@ -632,7 +630,7 @@ export default function ClientsPage() {
|
||||||
open={summary.online.length ? undefined : false}
|
open={summary.online.length ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<div className="client-email-list">{summary.online.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||||
>
|
>
|
||||||
<CustomStatistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
|
<Statistic title={t('online')} value={String(summary.online.length)} prefix={<span className="dot dot-blue" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
|
@ -641,7 +639,7 @@ export default function ClientsPage() {
|
||||||
open={summary.depleted.length ? undefined : false}
|
open={summary.depleted.length ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<div className="client-email-list">{summary.depleted.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||||
>
|
>
|
||||||
<CustomStatistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
|
<Statistic title={t('depleted')} value={String(summary.depleted.length)} prefix={<span className="dot dot-red" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
|
@ -650,7 +648,7 @@ export default function ClientsPage() {
|
||||||
open={summary.expiring.length ? undefined : false}
|
open={summary.expiring.length ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<div className="client-email-list">{summary.expiring.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||||
>
|
>
|
||||||
<CustomStatistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
|
<Statistic title={t('depletingSoon')} value={String(summary.expiring.length)} prefix={<span className="dot dot-orange" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
|
@ -659,11 +657,11 @@ export default function ClientsPage() {
|
||||||
open={summary.deactive.length ? undefined : false}
|
open={summary.deactive.length ? undefined : false}
|
||||||
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
|
content={<div className="client-email-list">{summary.deactive.map((e) => <div key={e}>{e}</div>)}</div>}
|
||||||
>
|
>
|
||||||
<CustomStatistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
|
<Statistic title={t('disabled')} value={String(summary.deactive.length)} prefix={<span className="dot dot-gray" />} />
|
||||||
</Popover>
|
</Popover>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<CustomStatistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
|
<Statistic title={t('subscription.active')} value={String(summary.active)} prefix={<span className="dot dot-green" />} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -838,7 +836,7 @@ export default function ClientsPage() {
|
||||||
checked={selectedRowKeys.includes(row.email)}
|
checked={selectedRowKeys.includes(row.email)}
|
||||||
onChange={(e) => toggleSelect(row.email, e.target.checked)}
|
onChange={(e) => toggleSelect(row.email, e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<Badge color={bucketBadgeColor(bucket)} />
|
<Badge status={bucketBadgeStatus(bucket)} />
|
||||||
<span className="tag-name">{row.email}</span>
|
<span className="tag-name">{row.email}</span>
|
||||||
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
|
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
|
||||||
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
|
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,3 @@
|
||||||
.mt-4 { margin-top: 4px; }
|
|
||||||
.mt-8 { margin-top: 8px; }
|
|
||||||
.mt-12 { margin-top: 12px; }
|
|
||||||
.mb-4 { margin-bottom: 4px; }
|
|
||||||
.mb-8 { margin-bottom: 8px; }
|
|
||||||
.mb-12 { margin-bottom: 12px; }
|
|
||||||
|
|
||||||
.random-icon {
|
|
||||||
margin-left: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--ant-color-primary, #1890ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-icon {
|
|
||||||
margin-left: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vless-auth-state {
|
.vless-auth-state {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|
@ -34,9 +15,9 @@
|
||||||
|
|
||||||
.advanced-panel {
|
.advanced-panel {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(128, 128, 128, 0.04);
|
background: var(--ant-color-fill-quaternary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.advanced-panel__header {
|
.advanced-panel__header {
|
||||||
|
|
@ -79,9 +60,3 @@
|
||||||
padding-inline: 10px;
|
padding-inline: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .advanced-panel,
|
|
||||||
html[data-theme='ultra-dark'] .advanced-panel {
|
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
|
@ -55,8 +54,8 @@ import {
|
||||||
DOMAIN_STRATEGY_OPTION,
|
DOMAIN_STRATEGY_OPTION,
|
||||||
TCP_CONGESTION_OPTION,
|
TCP_CONGESTION_OPTION,
|
||||||
MODE_OPTION,
|
MODE_OPTION,
|
||||||
} from '@/models/inbound.js';
|
} from '@/models/inbound';
|
||||||
import { DBInbound } from '@/models/dbinbound.js';
|
import { DBInbound } from '@/models/dbinbound';
|
||||||
import FinalMaskForm from '@/components/FinalMaskForm';
|
import FinalMaskForm from '@/components/FinalMaskForm';
|
||||||
import DateTimePicker from '@/components/DateTimePicker';
|
import DateTimePicker from '@/components/DateTimePicker';
|
||||||
import JsonEditor from '@/components/JsonEditor';
|
import JsonEditor from '@/components/JsonEditor';
|
||||||
|
|
@ -71,11 +70,75 @@ interface InboundFormModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
mode: 'add' | 'edit';
|
mode: 'add' | 'edit';
|
||||||
dbInbound: any;
|
dbInbound: DBInbound | null;
|
||||||
dbInbounds: any[];
|
dbInbounds: DBInbound[];
|
||||||
availableNodes?: NodeRecord[];
|
availableNodes?: NodeRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StreamLike {
|
||||||
|
network?: string;
|
||||||
|
tcp?: { type?: string; request?: { path?: string[] }; acceptProxyProtocol?: boolean };
|
||||||
|
ws?: { path?: string; acceptProxyProtocol?: boolean };
|
||||||
|
grpc?: { serviceName?: string; multiMode?: boolean };
|
||||||
|
httpupgrade?: { path?: string; acceptProxyProtocol?: boolean };
|
||||||
|
xhttp?: { path?: string };
|
||||||
|
security?: string;
|
||||||
|
tls?: { certs?: TlsCert[] };
|
||||||
|
reality?: unknown;
|
||||||
|
externalProxy?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TlsCert {
|
||||||
|
useFile?: boolean;
|
||||||
|
certFile?: string;
|
||||||
|
keyFile?: string;
|
||||||
|
cert?: string;
|
||||||
|
key?: string;
|
||||||
|
ocspStapling?: number;
|
||||||
|
oneTimeLoading?: boolean;
|
||||||
|
usage?: string;
|
||||||
|
buildChain?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VlessClient {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
flow?: string;
|
||||||
|
enable?: boolean;
|
||||||
|
subId?: string;
|
||||||
|
totalGB?: number;
|
||||||
|
expiryTime?: number;
|
||||||
|
limitIp?: number;
|
||||||
|
comment?: string;
|
||||||
|
tgId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShadowsocksClient {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
method?: string;
|
||||||
|
enable?: boolean;
|
||||||
|
subId?: string;
|
||||||
|
totalGB?: number;
|
||||||
|
expiryTime?: number;
|
||||||
|
limitIp?: number;
|
||||||
|
comment?: string;
|
||||||
|
tgId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpAccount {
|
||||||
|
user?: string;
|
||||||
|
pass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireguardPeer {
|
||||||
|
privateKey?: string;
|
||||||
|
publicKey?: string;
|
||||||
|
psk?: string;
|
||||||
|
allowedIPs: string[];
|
||||||
|
keepAlive?: number;
|
||||||
|
}
|
||||||
|
|
||||||
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
|
||||||
const PROTOCOLS = Object.values(Protocols) as string[];
|
const PROTOCOLS = Object.values(Protocols) as string[];
|
||||||
const TLS_VERSIONS = Object.values(TLS_VERSION_OPTION) as string[];
|
const TLS_VERSIONS = Object.values(TLS_VERSION_OPTION) as string[];
|
||||||
|
|
@ -107,12 +170,12 @@ interface FallbackRow {
|
||||||
xver: number;
|
xver: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveFallbackDefaults(childDb: any): Omit<FallbackRow, 'rowKey' | 'childId'> {
|
function deriveFallbackDefaults(childDb: DBInbound | null | undefined): Omit<FallbackRow, 'rowKey' | 'childId'> {
|
||||||
const out = { name: '', alpn: '', path: '', xver: 0 };
|
const out = { name: '', alpn: '', path: '', xver: 0 };
|
||||||
if (!childDb) return out;
|
if (!childDb) return out;
|
||||||
let stream: any;
|
let stream: StreamLike | undefined;
|
||||||
try {
|
try {
|
||||||
stream = childDb.toInbound()?.stream;
|
stream = childDb.toInbound()?.stream as StreamLike | undefined;
|
||||||
} catch {
|
} catch {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +229,9 @@ export default function InboundFormModal({
|
||||||
[availableNodes],
|
[availableNodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const inboundRef = useRef<any>(null);
|
const inboundRef = useRef<any>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const dbFormRef = useRef<any>(null);
|
const dbFormRef = useRef<any>(null);
|
||||||
const fallbackKeyRef = useRef(0);
|
const fallbackKeyRef = useRef(0);
|
||||||
const advancedTextRef = useRef({ stream: '', sniffing: '', settings: '' });
|
const advancedTextRef = useRef({ stream: '', sniffing: '', settings: '' });
|
||||||
|
|
@ -279,9 +344,9 @@ export default function InboundFormModal({
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setFallbackEditing(new Set());
|
setFallbackEditing(new Set());
|
||||||
if (mode === 'edit' && dbInbound) {
|
if (mode === 'edit' && dbInbound) {
|
||||||
const parsed = (Inbound as any).fromJson(dbInbound.toInbound().toJson());
|
const parsed = Inbound.fromJson(dbInbound.toInbound().toJson());
|
||||||
inboundRef.current = parsed;
|
inboundRef.current = parsed;
|
||||||
dbFormRef.current = new (DBInbound as any)(dbInbound);
|
dbFormRef.current = new DBInbound(dbInbound);
|
||||||
primeAdvancedJson();
|
primeAdvancedJson();
|
||||||
if (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN) {
|
if (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN) {
|
||||||
loadFallbacks(dbInbound.id);
|
loadFallbacks(dbInbound.id);
|
||||||
|
|
@ -289,12 +354,12 @@ export default function InboundFormModal({
|
||||||
setFallbacks([]);
|
setFallbacks([]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const ib = new (Inbound as any)();
|
const ib = new Inbound();
|
||||||
ib.protocol = Protocols.VLESS;
|
ib.protocol = Protocols.VLESS;
|
||||||
ib.settings = (Inbound as any).Settings.getSettings(Protocols.VLESS);
|
ib.settings = Inbound.Settings.getSettings(Protocols.VLESS);
|
||||||
ib.port = RandomUtil.randomInteger(10000, 60000);
|
ib.port = RandomUtil.randomInteger(10000, 60000);
|
||||||
inboundRef.current = ib;
|
inboundRef.current = ib;
|
||||||
const form = new (DBInbound as any)();
|
const form = new DBInbound();
|
||||||
form.enable = true;
|
form.enable = true;
|
||||||
form.remark = '';
|
form.remark = '';
|
||||||
form.total = 0;
|
form.total = 0;
|
||||||
|
|
@ -333,7 +398,7 @@ export default function InboundFormModal({
|
||||||
const ib = inboundRef.current;
|
const ib = inboundRef.current;
|
||||||
if (mode === 'edit' || !ib) return;
|
if (mode === 'edit' || !ib) return;
|
||||||
ib.protocol = next;
|
ib.protocol = next;
|
||||||
ib.settings = (Inbound as any).Settings.getSettings(next);
|
ib.settings = Inbound.Settings.getSettings(next);
|
||||||
if (!NODE_ELIGIBLE_PROTOCOLS.has(next) && dbFormRef.current) {
|
if (!NODE_ELIGIBLE_PROTOCOLS.has(next) && dbFormRef.current) {
|
||||||
dbFormRef.current.nodeId = null;
|
dbFormRef.current.nodeId = null;
|
||||||
}
|
}
|
||||||
|
|
@ -352,7 +417,7 @@ export default function InboundFormModal({
|
||||||
&& !ib.canEnableTlsFlow()
|
&& !ib.canEnableTlsFlow()
|
||||||
&& Array.isArray(ib.settings.vlesses)
|
&& Array.isArray(ib.settings.vlesses)
|
||||||
) {
|
) {
|
||||||
ib.settings.vlesses.forEach((c: any) => { c.flow = ''; });
|
ib.settings.vlesses.forEach((c: VlessClient) => { c.flow = ''; });
|
||||||
}
|
}
|
||||||
if (next !== 'kcp' && ib.stream.finalmask) {
|
if (next !== 'kcp' && ib.stream.finalmask) {
|
||||||
ib.stream.finalmask.udp = [];
|
ib.stream.finalmask.udp = [];
|
||||||
|
|
@ -379,7 +444,7 @@ export default function InboundFormModal({
|
||||||
xver: 0,
|
xver: 0,
|
||||||
};
|
};
|
||||||
if (childId) {
|
if (childId) {
|
||||||
const child = (dbInbounds || []).find((ib: any) => ib.id === childId);
|
const child = (dbInbounds || []).find((ib) => ib.id === childId);
|
||||||
Object.assign(row, deriveFallbackDefaults(child));
|
Object.assign(row, deriveFallbackDefaults(child));
|
||||||
}
|
}
|
||||||
setFallbacks((prev) => [...prev, row]);
|
setFallbacks((prev) => [...prev, row]);
|
||||||
|
|
@ -402,7 +467,7 @@ export default function InboundFormModal({
|
||||||
const onFallbackChildPicked = useCallback((rowKey: string, childId: number) => {
|
const onFallbackChildPicked = useCallback((rowKey: string, childId: number) => {
|
||||||
setFallbacks((prev) => prev.map((row) => {
|
setFallbacks((prev) => prev.map((row) => {
|
||||||
if (row.rowKey !== rowKey) return row;
|
if (row.rowKey !== rowKey) return row;
|
||||||
const child = (dbInbounds || []).find((ib: any) => ib.id === childId);
|
const child = (dbInbounds || []).find((ib) => ib.id === childId);
|
||||||
const defaults = deriveFallbackDefaults(child);
|
const defaults = deriveFallbackDefaults(child);
|
||||||
return { ...row, childId, ...defaults };
|
return { ...row, childId, ...defaults };
|
||||||
}));
|
}));
|
||||||
|
|
@ -415,7 +480,7 @@ export default function InboundFormModal({
|
||||||
const rederiveFallback = useCallback((rowKey: string) => {
|
const rederiveFallback = useCallback((rowKey: string) => {
|
||||||
setFallbacks((prev) => prev.map((row) => {
|
setFallbacks((prev) => prev.map((row) => {
|
||||||
if (row.rowKey !== rowKey || !row.childId) return row;
|
if (row.rowKey !== rowKey || !row.childId) return row;
|
||||||
const child = (dbInbounds || []).find((ib: any) => ib.id === row.childId);
|
const child = (dbInbounds || []).find((ib) => ib.id === row.childId);
|
||||||
const defaults = deriveFallbackDefaults(child);
|
const defaults = deriveFallbackDefaults(child);
|
||||||
return { ...row, ...defaults };
|
return { ...row, ...defaults };
|
||||||
}));
|
}));
|
||||||
|
|
@ -432,9 +497,9 @@ export default function InboundFormModal({
|
||||||
for (const ib of list) {
|
for (const ib of list) {
|
||||||
if (ib.id === masterId) continue;
|
if (ib.id === masterId) continue;
|
||||||
if (existing.has(ib.id)) continue;
|
if (existing.has(ib.id)) continue;
|
||||||
let stream: any;
|
let stream: StreamLike | undefined;
|
||||||
try { stream = ib.toInbound()?.stream; } catch { continue; }
|
try { stream = ib.toInbound()?.stream as StreamLike | undefined; } catch { continue; }
|
||||||
if (!stream || !FALLBACK_ELIGIBLE_TRANSPORTS.has(stream.network)) continue;
|
if (!stream || !FALLBACK_ELIGIBLE_TRANSPORTS.has(stream.network ?? '')) continue;
|
||||||
const row: FallbackRow = {
|
const row: FallbackRow = {
|
||||||
rowKey: `fb-${++fallbackKeyRef.current}`,
|
rowKey: `fb-${++fallbackKeyRef.current}`,
|
||||||
childId: ib.id,
|
childId: ib.id,
|
||||||
|
|
@ -456,8 +521,8 @@ export default function InboundFormModal({
|
||||||
const list = dbInbounds || [];
|
const list = dbInbounds || [];
|
||||||
const masterId = dbInbound?.id;
|
const masterId = dbInbound?.id;
|
||||||
return list
|
return list
|
||||||
.filter((ib: any) => ib.id !== masterId)
|
.filter((ib) => ib.id !== masterId)
|
||||||
.map((ib: any) => ({
|
.map((ib) => ({
|
||||||
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
|
||||||
value: ib.id,
|
value: ib.id,
|
||||||
}));
|
}));
|
||||||
|
|
@ -488,22 +553,22 @@ export default function InboundFormModal({
|
||||||
try { return await fn(); } finally { setSaving(false); }
|
try { return await fn(); } finally { setSaving(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const randomSSPassword = useCallback((target: any) => {
|
const randomSSPassword = useCallback((target: ShadowsocksClient) => {
|
||||||
if (target) {
|
if (target) {
|
||||||
target.password = (RandomUtil as any).randomShadowsocksPassword(inboundRef.current.settings.method);
|
target.password = RandomUtil.randomShadowsocksPassword(inboundRef.current.settings.method);
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const regenWgKeypair = useCallback((target: any) => {
|
const regenWgKeypair = useCallback((target: WireguardPeer) => {
|
||||||
const kp = (Wireguard as any).generateKeypair();
|
const kp = Wireguard.generateKeypair();
|
||||||
target.publicKey = kp.publicKey;
|
target.publicKey = kp.publicKey;
|
||||||
target.privateKey = kp.privateKey;
|
target.privateKey = kp.privateKey;
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const regenInboundWg = useCallback(() => {
|
const regenInboundWg = useCallback(() => {
|
||||||
const kp = (Wireguard as any).generateKeypair();
|
const kp = Wireguard.generateKeypair();
|
||||||
inboundRef.current.settings.pubKey = kp.publicKey;
|
inboundRef.current.settings.pubKey = kp.publicKey;
|
||||||
inboundRef.current.settings.secretKey = kp.privateKey;
|
inboundRef.current.settings.secretKey = kp.privateKey;
|
||||||
refresh();
|
refresh();
|
||||||
|
|
@ -557,7 +622,7 @@ export default function InboundFormModal({
|
||||||
|
|
||||||
const randomizeShortIds = useCallback(() => {
|
const randomizeShortIds = useCallback(() => {
|
||||||
if (!inboundRef.current?.stream?.reality) return;
|
if (!inboundRef.current?.stream?.reality) return;
|
||||||
inboundRef.current.stream.reality.shortIds = (RandomUtil as any).randomShortIds();
|
inboundRef.current.stream.reality.shortIds = RandomUtil.randomShortIds();
|
||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
|
@ -590,7 +655,7 @@ export default function InboundFormModal({
|
||||||
refresh();
|
refresh();
|
||||||
}, [defaultCert, defaultKey, refresh]);
|
}, [defaultCert, defaultKey, refresh]);
|
||||||
|
|
||||||
const matchesVlessAuth = useCallback((block: any, authId: string) => {
|
const matchesVlessAuth = useCallback((block: { id?: string; label?: string } | undefined | null, authId: string) => {
|
||||||
if (block?.id === authId) return true;
|
if (block?.id === authId) return true;
|
||||||
const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
|
const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||||
if (authId === 'mlkem768') return label.includes('mlkem768');
|
if (authId === 'mlkem768') return label.includes('mlkem768');
|
||||||
|
|
@ -633,11 +698,11 @@ export default function InboundFormModal({
|
||||||
|
|
||||||
const onSSMethodChange = useCallback(() => {
|
const onSSMethodChange = useCallback(() => {
|
||||||
const ib = inboundRef.current;
|
const ib = inboundRef.current;
|
||||||
ib.settings.password = (RandomUtil as any).randomShadowsocksPassword(ib.settings.method);
|
ib.settings.password = RandomUtil.randomShadowsocksPassword(ib.settings.method);
|
||||||
if (ib.isSSMultiUser) {
|
if (ib.isSSMultiUser) {
|
||||||
ib.settings.shadowsockses.forEach((c: any) => {
|
ib.settings.shadowsockses.forEach((c: ShadowsocksClient) => {
|
||||||
c.method = ib.isSS2022 ? '' : ib.settings.method;
|
c.method = ib.isSS2022 ? '' : ib.settings.method;
|
||||||
c.password = (RandomUtil as any).randomShadowsocksPassword(ib.settings.method);
|
c.password = RandomUtil.randomShadowsocksPassword(ib.settings.method);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ib.settings.shadowsockses = [];
|
ib.settings.shadowsockses = [];
|
||||||
|
|
@ -686,7 +751,7 @@ export default function InboundFormModal({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
inboundRef.current = (Inbound as any).fromJson({
|
inboundRef.current = Inbound.fromJson({
|
||||||
port: ib.port,
|
port: ib.port,
|
||||||
listen: ib.listen,
|
listen: ib.listen,
|
||||||
protocol: ib.protocol,
|
protocol: ib.protocol,
|
||||||
|
|
@ -781,17 +846,26 @@ export default function InboundFormModal({
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const setAdvancedAllValue = (next: string) => {
|
const setAdvancedAllValue = (next: string) => {
|
||||||
let parsed: any;
|
let parsedRaw: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(next);
|
parsedRaw = JSON.parse(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
messageApi.error(`All JSON invalid: ${(e as Error).message}`);
|
messageApi.error(`All JSON invalid: ${(e as Error).message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
if (!parsedRaw || typeof parsedRaw !== 'object' || Array.isArray(parsedRaw)) {
|
||||||
messageApi.error('All JSON must be an inbound object.');
|
messageApi.error('All JSON must be an inbound object.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const parsed = parsedRaw as {
|
||||||
|
listen?: string;
|
||||||
|
port?: number | string;
|
||||||
|
protocol?: string;
|
||||||
|
tag?: string;
|
||||||
|
settings?: unknown;
|
||||||
|
sniffing?: unknown;
|
||||||
|
streamSettings?: unknown;
|
||||||
|
};
|
||||||
const ib = inboundRef.current;
|
const ib = inboundRef.current;
|
||||||
try {
|
try {
|
||||||
if (typeof parsed.listen === 'string') ib.listen = parsed.listen;
|
if (typeof parsed.listen === 'string') ib.listen = parsed.listen;
|
||||||
|
|
@ -857,7 +931,7 @@ export default function InboundFormModal({
|
||||||
settings = compactAdvancedJson(advancedTextRef.current.settings, ib.settings.toString(), t('pages.inbounds.advanced.settings'));
|
settings = compactAdvancedJson(advancedTextRef.current.settings, ib.settings.toString(), t('pages.inbounds.advanced.settings'));
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
|
|
||||||
const payload: any = {
|
const payload: Record<string, unknown> = {
|
||||||
up: form.up || 0,
|
up: form.up || 0,
|
||||||
down: form.down || 0,
|
down: form.down || 0,
|
||||||
total: form.total,
|
total: form.total,
|
||||||
|
|
@ -876,14 +950,15 @@ export default function InboundFormModal({
|
||||||
if (form.nodeId != null) payload.nodeId = form.nodeId;
|
if (form.nodeId != null) payload.nodeId = form.nodeId;
|
||||||
|
|
||||||
const url = mode === 'edit'
|
const url = mode === 'edit'
|
||||||
? `/panel/api/inbounds/update/${dbInbound.id}`
|
? `/panel/api/inbounds/update/${dbInbound!.id}`
|
||||||
: '/panel/api/inbounds/add';
|
: '/panel/api/inbounds/add';
|
||||||
const msg = await HttpUtil.post(url, payload);
|
const msg = await HttpUtil.post(url, payload);
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
if (isFallbackHost) {
|
if (isFallbackHost) {
|
||||||
|
const obj = msg.obj as { id?: number; Id?: number } | null;
|
||||||
const masterId = mode === 'edit'
|
const masterId = mode === 'edit'
|
||||||
? dbInbound.id
|
? dbInbound!.id
|
||||||
: ((msg.obj as any)?.id || (msg.obj as any)?.Id);
|
: (obj?.id || obj?.Id);
|
||||||
if (masterId) await saveFallbacks(masterId);
|
if (masterId) await saveFallbacks(masterId);
|
||||||
}
|
}
|
||||||
onSaved();
|
onSaved();
|
||||||
|
|
@ -1155,8 +1230,8 @@ export default function InboundFormModal({
|
||||||
<Form.Item label="Accounts">
|
<Form.Item label="Accounts">
|
||||||
<Button size="small" onClick={() => {
|
<Button size="small" onClick={() => {
|
||||||
const Account = ib.protocol === Protocols.HTTP
|
const Account = ib.protocol === Protocols.HTTP
|
||||||
? (Inbound as any).HttpSettings.HttpAccount
|
? Inbound.HttpSettings.HttpAccount
|
||||||
: (Inbound as any).MixedSettings.SocksAccount;
|
: Inbound.MixedSettings.SocksAccount;
|
||||||
ib.settings.addAccount(new Account());
|
ib.settings.addAccount(new Account());
|
||||||
refresh();
|
refresh();
|
||||||
}}>
|
}}>
|
||||||
|
|
@ -1164,7 +1239,7 @@ export default function InboundFormModal({
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item wrapperCol={{ span: 24 }}>
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
{(ib.settings.accounts || []).map((account: any, idx: number) => (
|
{(ib.settings.accounts || []).map((account: HttpAccount, idx: number) => (
|
||||||
<Space.Compact key={idx} className="mb-8" block>
|
<Space.Compact key={idx} className="mb-8" block>
|
||||||
<InputAddon>{String(idx + 1)}</InputAddon>
|
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||||
<Input value={account.user} placeholder="Username"
|
<Input value={account.user} placeholder="Username"
|
||||||
|
|
@ -1337,7 +1412,7 @@ export default function InboundFormModal({
|
||||||
<PlusOutlined /> Add peer
|
<PlusOutlined /> Add peer
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{(ib.settings.peers || []).map((peer: any, idx: number) => (
|
{(ib.settings.peers || []).map((peer: WireguardPeer, idx: number) => (
|
||||||
<div key={idx} className="wg-peer">
|
<div key={idx} className="wg-peer">
|
||||||
<Divider style={{ margin: '8px 0' }}>
|
<Divider style={{ margin: '8px 0' }}>
|
||||||
Peer {idx + 1}
|
Peer {idx + 1}
|
||||||
|
|
@ -1906,7 +1981,7 @@ export default function InboundFormModal({
|
||||||
<Form.Item label="Disable System Root"><Switch checked={!!ib.stream.tls.disableSystemRoot} onChange={(v) => { ib.stream.tls.disableSystemRoot = v; refresh(); }} /></Form.Item>
|
<Form.Item label="Disable System Root"><Switch checked={!!ib.stream.tls.disableSystemRoot} onChange={(v) => { ib.stream.tls.disableSystemRoot = v; refresh(); }} /></Form.Item>
|
||||||
<Form.Item label="Session Resumption"><Switch checked={!!ib.stream.tls.enableSessionResumption} onChange={(v) => { ib.stream.tls.enableSessionResumption = v; refresh(); }} /></Form.Item>
|
<Form.Item label="Session Resumption"><Switch checked={!!ib.stream.tls.enableSessionResumption} onChange={(v) => { ib.stream.tls.enableSessionResumption = v; refresh(); }} /></Form.Item>
|
||||||
|
|
||||||
{(ib.stream.tls.certs || []).map((cert: any, idx: number) => (
|
{(ib.stream.tls.certs || []).map((cert: TlsCert, idx: number) => (
|
||||||
<div key={`cert-${idx}`}>
|
<div key={`cert-${idx}`}>
|
||||||
<Form.Item label={t('certificate')}>
|
<Form.Item label={t('certificate')}>
|
||||||
<Radio.Group value={cert.useFile} buttonStyle="solid" onChange={(e) => { cert.useFile = e.target.value; refresh(); }}>
|
<Radio.Group value={cert.useFile} buttonStyle="solid" onChange={(e) => { cert.useFile = e.target.value; refresh(); }}>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row:last-child {
|
.info-row:last-child {
|
||||||
|
|
@ -95,16 +95,12 @@
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--ant-color-fill-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .value-code {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-copy {
|
.value-copy {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +108,7 @@ body.dark .value-code {
|
||||||
.share-buttons {
|
.share-buttons {
|
||||||
margin-inline-start: 4px;
|
margin-inline-start: 4px;
|
||||||
padding-inline-start: 8px;
|
padding-inline-start: 8px;
|
||||||
border-inline-start: 1px solid rgba(128, 128, 128, 0.25);
|
border-inline-start: 1px solid var(--ant-color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-table {
|
.summary-table {
|
||||||
|
|
@ -157,7 +153,7 @@ body.dark .value-code {
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-panel {
|
.link-panel {
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
border: 1px solid var(--ant-color-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
@ -179,37 +175,25 @@ body.dark .value-code {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--ant-color-fill-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .link-panel-text {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-panel-anchor {
|
.link-panel-anchor {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--ant-color-fill-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--ant-color-primary, #1677ff);
|
color: var(--ant-color-primary);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-color: rgba(22, 119, 255, 0.4);
|
text-decoration-color: color-mix(in srgb, var(--ant-color-primary) 40%, transparent);
|
||||||
transition: background 120ms ease, text-decoration-color 120ms ease;
|
transition: background 120ms ease, text-decoration-color 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-panel-anchor:hover {
|
.link-panel-anchor:hover {
|
||||||
background: rgba(22, 119, 255, 0.08);
|
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||||
text-decoration-color: var(--ant-color-primary, #1677ff);
|
text-decoration-color: var(--ant-color-primary);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .link-panel-anchor {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .link-panel-anchor:hover {
|
|
||||||
background: rgba(22, 119, 255, 0.16);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
ClipboardManager,
|
ClipboardManager,
|
||||||
FileManager,
|
FileManager,
|
||||||
} from '@/utils';
|
} from '@/utils';
|
||||||
import { Protocols } from '@/models/inbound.js';
|
import { Protocols } from '@/models/inbound';
|
||||||
import InfinityIcon from '@/components/InfinityIcon';
|
import InfinityIcon from '@/components/InfinityIcon';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import type { SubSettings } from './useInbounds';
|
import type { SubSettings } from './useInbounds';
|
||||||
|
|
|
||||||
|
|
@ -32,29 +32,29 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table {
|
.inbounds-page .ant-table {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-container {
|
.inbounds-page .ant-table-container {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-thead > tr:first-child > *:first-child {
|
.inbounds-page .ant-table-thead > tr:first-child > *:first-child {
|
||||||
border-start-start-radius: 8px;
|
border-start-start-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-thead > tr:first-child > *:last-child {
|
.inbounds-page .ant-table-thead > tr:first-child > *:last-child {
|
||||||
border-start-end-radius: 8px;
|
border-start-end-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-tbody > tr:last-child > *:first-child {
|
.inbounds-page .ant-table-tbody > tr:last-child > *:first-child {
|
||||||
border-end-start-radius: 8px;
|
border-end-start-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-tbody > tr:last-child > *:last-child {
|
.inbounds-page .ant-table-tbody > tr:last-child > *:last-child {
|
||||||
border-end-end-radius: 8px;
|
border-end-end-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,20 +66,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.inbound-card {
|
.inbound-card {
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--ant-color-fill-quaternary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .inbound-card {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-head {
|
.card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -142,21 +137,21 @@ body.dark .inbound-card {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ant-card-head {
|
.inbounds-page .ant-card-head {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-card-head-title,
|
.inbounds-page .ant-card-head-title,
|
||||||
.ant-card-extra {
|
.inbounds-page .ant-card-extra {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-card-body {
|
.inbounds-page .ant-card-body {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-action-trigger {
|
.inbounds-page .row-action-trigger {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ interface DBInboundRecord extends ProtocolFlags {
|
||||||
down: number;
|
down: number;
|
||||||
total: number;
|
total: number;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
_expiryTime: unknown;
|
_expiryTime: { valueOf(): number } | null;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
toInbound: () => {
|
toInbound: () => {
|
||||||
stream?: { network?: string; isTls?: boolean; isReality?: boolean };
|
stream?: { network?: string; isTls?: boolean; isReality?: boolean };
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
.inbounds-page {
|
|
||||||
--bg-page: #e6e8ec;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inbounds-page.is-dark {
|
|
||||||
--bg-page: #1a1b1f;
|
|
||||||
--bg-card: #23252b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inbounds-page.is-dark.is-ultra {
|
|
||||||
--bg-page: #000;
|
|
||||||
--bg-card: #101013;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inbounds-page .ant-layout,
|
|
||||||
.inbounds-page .ant-layout-content {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-shell {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-area {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.content-area {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spacer {
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.summary-card {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,6 +8,7 @@ import {
|
||||||
Modal,
|
Modal,
|
||||||
Row,
|
Row,
|
||||||
Spin,
|
Spin,
|
||||||
|
Statistic,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
|
||||||
|
|
@ -20,14 +20,13 @@ import {
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||||
import { Inbound } from '@/models/inbound.js';
|
import { Inbound } from '@/models/inbound';
|
||||||
import { coerceInboundJsonField } from '@/models/dbinbound.js';
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||||
import AppSidebar from '@/components/AppSidebar';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import CustomStatistic from '@/components/CustomStatistic';
|
|
||||||
const TextModal = lazy(() => import('@/components/TextModal'));
|
const TextModal = lazy(() => import('@/components/TextModal'));
|
||||||
const PromptModal = lazy(() => import('@/components/PromptModal'));
|
const PromptModal = lazy(() => import('@/components/PromptModal'));
|
||||||
|
|
||||||
|
|
@ -37,8 +36,6 @@ import LazyMount from '@/components/LazyMount';
|
||||||
const InboundFormModal = lazy(() => import('./InboundFormModal'));
|
const InboundFormModal = lazy(() => import('./InboundFormModal'));
|
||||||
const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
|
const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
|
||||||
const QrCodeModal = lazy(() => import('./QrCodeModal'));
|
const QrCodeModal = lazy(() => import('./QrCodeModal'));
|
||||||
import '@/styles/page-cards.css';
|
|
||||||
import './InboundsPage.css';
|
|
||||||
|
|
||||||
type RowAction =
|
type RowAction =
|
||||||
| 'edit'
|
| 'edit'
|
||||||
|
|
@ -53,6 +50,12 @@ type RowAction =
|
||||||
|
|
||||||
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
||||||
|
|
||||||
|
interface ClientMatchTarget {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function InboundsPage() {
|
export default function InboundsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
|
|
@ -94,7 +97,7 @@ export default function InboundsPage() {
|
||||||
[nodesList],
|
[nodesList],
|
||||||
);
|
);
|
||||||
const hasNodeAttachedInbound = useMemo(
|
const hasNodeAttachedInbound = useMemo(
|
||||||
() => (dbInbounds || []).some((ib: any) => ib?.nodeId != null),
|
() => (dbInbounds || []).some((ib) => ib?.nodeId != null),
|
||||||
[dbInbounds],
|
[dbInbounds],
|
||||||
);
|
);
|
||||||
const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
|
const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
|
||||||
|
|
@ -106,14 +109,14 @@ export default function InboundsPage() {
|
||||||
|
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||||
const [formDbInbound, setFormDbInbound] = useState<any>(null);
|
const [formDbInbound, setFormDbInbound] = useState<DBInbound | null>(null);
|
||||||
|
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const [infoDbInbound, setInfoDbInbound] = useState<any>(null);
|
const [infoDbInbound, setInfoDbInbound] = useState<DBInbound | null>(null);
|
||||||
const [infoClientIndex, setInfoClientIndex] = useState(0);
|
const [infoClientIndex, setInfoClientIndex] = useState(0);
|
||||||
|
|
||||||
const [qrOpen, setQrOpen] = useState(false);
|
const [qrOpen, setQrOpen] = useState(false);
|
||||||
const [qrDbInbound, setQrDbInbound] = useState<any>(null);
|
const [qrDbInbound, setQrDbInbound] = useState<DBInbound | null>(null);
|
||||||
|
|
||||||
const [textOpen, setTextOpen] = useState(false);
|
const [textOpen, setTextOpen] = useState(false);
|
||||||
const [textTitle, setTextTitle] = useState('');
|
const [textTitle, setTextTitle] = useState('');
|
||||||
|
|
@ -128,7 +131,7 @@ export default function InboundsPage() {
|
||||||
const [promptLoading, setPromptLoading] = useState(false);
|
const [promptLoading, setPromptLoading] = useState(false);
|
||||||
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
|
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
|
||||||
|
|
||||||
const hostOverrideFor = useCallback((dbInbound: any) => {
|
const hostOverrideFor = useCallback((dbInbound: DBInbound | null) => {
|
||||||
if (!dbInbound || dbInbound.nodeId == null) return '';
|
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||||
return nodesById.get(dbInbound.nodeId)?.address || '';
|
return nodesById.get(dbInbound.nodeId)?.address || '';
|
||||||
}, [nodesById]);
|
}, [nodesById]);
|
||||||
|
|
@ -172,8 +175,8 @@ export default function InboundsPage() {
|
||||||
}
|
}
|
||||||
}, [promptHandler]);
|
}, [promptHandler]);
|
||||||
|
|
||||||
const projectChildThroughMaster = useCallback((child: any, master: any) => {
|
const projectChildThroughMaster = useCallback((child: DBInbound, master: DBInbound): DBInbound => {
|
||||||
const projected = JSON.parse(JSON.stringify(child));
|
const projected = JSON.parse(JSON.stringify(child)) as DBInbound;
|
||||||
projected.listen = master.listen;
|
projected.listen = master.listen;
|
||||||
projected.port = master.port;
|
projected.port = master.port;
|
||||||
const masterStream = master.toInbound().stream;
|
const masterStream = master.toInbound().stream;
|
||||||
|
|
@ -183,17 +186,18 @@ export default function InboundsPage() {
|
||||||
childInbound.stream.reality = masterStream.reality;
|
childInbound.stream.reality = masterStream.reality;
|
||||||
childInbound.stream.externalProxy = masterStream.externalProxy;
|
childInbound.stream.externalProxy = masterStream.externalProxy;
|
||||||
projected.streamSettings = childInbound.stream.toString();
|
projected.streamSettings = childInbound.stream.toString();
|
||||||
return new child.constructor(projected);
|
const Ctor = child.constructor as new (data: DBInbound) => DBInbound;
|
||||||
|
return new Ctor(projected);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkFallback = useCallback((dbInbound: any) => {
|
const checkFallback = useCallback((dbInbound: DBInbound): DBInbound => {
|
||||||
const parent = dbInbound?.fallbackParent;
|
const parent = dbInbound?.fallbackParent;
|
||||||
if (parent?.masterId) {
|
if (parent?.masterId) {
|
||||||
const master = (dbInbounds as any[]).find((ib: any) => ib.id === parent.masterId);
|
const master = dbInbounds.find((ib) => ib.id === parent.masterId);
|
||||||
if (master) return projectChildThroughMaster(dbInbound, master);
|
if (master) return projectChildThroughMaster(dbInbound, master);
|
||||||
}
|
}
|
||||||
if (!(dbInbound?.listen as string | undefined)?.startsWith?.('@')) return dbInbound;
|
if (!dbInbound?.listen?.startsWith?.('@')) return dbInbound;
|
||||||
for (const candidate of dbInbounds as any[]) {
|
for (const candidate of dbInbounds) {
|
||||||
if (candidate.id === dbInbound.id) continue;
|
if (candidate.id === dbInbound.id) continue;
|
||||||
const parsed = candidate.toInbound();
|
const parsed = candidate.toInbound();
|
||||||
if (!parsed.isTcp) continue;
|
if (!parsed.isTcp) continue;
|
||||||
|
|
@ -205,11 +209,11 @@ export default function InboundsPage() {
|
||||||
return dbInbound;
|
return dbInbound;
|
||||||
}, [dbInbounds, projectChildThroughMaster]);
|
}, [dbInbounds, projectChildThroughMaster]);
|
||||||
|
|
||||||
const findClientIndex = useCallback((dbInbound: any, client: any) => {
|
const findClientIndex = useCallback((dbInbound: DBInbound, client: ClientMatchTarget | null) => {
|
||||||
if (!client) return 0;
|
if (!client) return 0;
|
||||||
const inbound = dbInbound.toInbound();
|
const inbound = dbInbound.toInbound();
|
||||||
const clients = inbound?.clients || [];
|
const clients = (inbound?.clients || []) as ClientMatchTarget[];
|
||||||
const idx = clients.findIndex((c: any) => {
|
const idx = clients.findIndex((c) => {
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
switch (dbInbound.protocol) {
|
switch (dbInbound.protocol) {
|
||||||
case 'trojan':
|
case 'trojan':
|
||||||
|
|
@ -222,7 +226,7 @@ export default function InboundsPage() {
|
||||||
return idx >= 0 ? idx : 0;
|
return idx >= 0 ? idx : 0;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const exportInboundLinks = useCallback((dbInbound: any) => {
|
const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
|
||||||
const projected = checkFallback(dbInbound);
|
const projected = checkFallback(dbInbound);
|
||||||
openText({
|
openText({
|
||||||
title: t('pages.inbounds.exportLinksTitle'),
|
title: t('pages.inbounds.exportLinksTitle'),
|
||||||
|
|
@ -231,13 +235,13 @@ export default function InboundsPage() {
|
||||||
});
|
});
|
||||||
}, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
|
}, [checkFallback, remarkModel, hostOverrideFor, openText, t]);
|
||||||
|
|
||||||
const exportInboundClipboard = useCallback((dbInbound: any) => {
|
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
|
||||||
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
|
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) });
|
||||||
}, [openText, t]);
|
}, [openText, t]);
|
||||||
|
|
||||||
const exportInboundSubs = useCallback((dbInbound: any) => {
|
const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
|
||||||
const inbound = dbInbound.toInbound();
|
const inbound = dbInbound.toInbound();
|
||||||
const clients = inbound?.clients || [];
|
const clients = (inbound?.clients || []) as { subId?: string }[];
|
||||||
const subLinks: string[] = [];
|
const subLinks: string[] = [];
|
||||||
for (const c of clients) {
|
for (const c of clients) {
|
||||||
if (c.subId && subSettings.subURI) {
|
if (c.subId && subSettings.subURI) {
|
||||||
|
|
@ -253,7 +257,7 @@ export default function InboundsPage() {
|
||||||
|
|
||||||
const exportAllLinks = useCallback(async () => {
|
const exportAllLinks = useCallback(async () => {
|
||||||
const hydrated = await Promise.all(
|
const hydrated = await Promise.all(
|
||||||
(dbInbounds as any[]).map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
||||||
);
|
);
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const ib of hydrated) {
|
for (const ib of hydrated) {
|
||||||
|
|
@ -265,12 +269,12 @@ export default function InboundsPage() {
|
||||||
|
|
||||||
const exportAllSubs = useCallback(async () => {
|
const exportAllSubs = useCallback(async () => {
|
||||||
const hydrated = await Promise.all(
|
const hydrated = await Promise.all(
|
||||||
(dbInbounds as any[]).map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
|
||||||
);
|
);
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const ib of hydrated) {
|
for (const ib of hydrated) {
|
||||||
const inbound = ib.toInbound();
|
const inbound = ib.toInbound();
|
||||||
const clients = inbound?.clients || [];
|
const clients = (inbound?.clients || []) as { subId?: string }[];
|
||||||
for (const c of clients) {
|
for (const c of clients) {
|
||||||
if (c.subId && subSettings.subURI) {
|
if (c.subId && subSettings.subURI) {
|
||||||
out.push(subSettings.subURI + c.subId);
|
out.push(subSettings.subURI + c.subId);
|
||||||
|
|
@ -303,13 +307,13 @@ export default function InboundsPage() {
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openEdit = useCallback((dbInbound: any) => {
|
const openEdit = useCallback((dbInbound: DBInbound) => {
|
||||||
setFormMode('edit');
|
setFormMode('edit');
|
||||||
setFormDbInbound(dbInbound);
|
setFormDbInbound(dbInbound);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const confirmDelete = useCallback((dbInbound: any) => {
|
const confirmDelete = useCallback((dbInbound: DBInbound) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
|
title: t('pages.inbounds.deleteConfirmTitle', { remark: dbInbound.remark }),
|
||||||
content: t('pages.inbounds.deleteConfirmContent'),
|
content: t('pages.inbounds.deleteConfirmContent'),
|
||||||
|
|
@ -323,7 +327,7 @@ export default function InboundsPage() {
|
||||||
});
|
});
|
||||||
}, [modal, refresh, t]);
|
}, [modal, refresh, t]);
|
||||||
|
|
||||||
const confirmResetTraffic = useCallback((dbInbound: any) => {
|
const confirmResetTraffic = useCallback((dbInbound: DBInbound) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
|
title: t('pages.inbounds.resetConfirmTitle', { remark: dbInbound.remark }),
|
||||||
content: t('pages.inbounds.resetConfirmContent'),
|
content: t('pages.inbounds.resetConfirmContent'),
|
||||||
|
|
@ -336,7 +340,7 @@ export default function InboundsPage() {
|
||||||
});
|
});
|
||||||
}, [modal, refresh, t]);
|
}, [modal, refresh, t]);
|
||||||
|
|
||||||
const confirmClone = useCallback((dbInbound: any) => {
|
const confirmClone = useCallback((dbInbound: DBInbound) => {
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
|
title: t('pages.inbounds.cloneConfirmTitle', { remark: dbInbound.remark }),
|
||||||
content: t('pages.inbounds.cloneConfirmContent'),
|
content: t('pages.inbounds.cloneConfirmContent'),
|
||||||
|
|
@ -350,7 +354,7 @@ export default function InboundsPage() {
|
||||||
raw.clients = [];
|
raw.clients = [];
|
||||||
clonedSettings = JSON.stringify(raw);
|
clonedSettings = JSON.stringify(raw);
|
||||||
} catch {
|
} catch {
|
||||||
clonedSettings = (Inbound as any).Settings.getSettings(baseInbound.protocol).toString();
|
clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
up: 0,
|
up: 0,
|
||||||
|
|
@ -393,7 +397,7 @@ export default function InboundsPage() {
|
||||||
}
|
}
|
||||||
}, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
|
}, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
|
||||||
|
|
||||||
const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: any }) => {
|
const onRowAction = useCallback(async ({ key, dbInbound }: { key: RowAction; dbInbound: DBInbound }) => {
|
||||||
// Actions that touch per-client secrets (uuid, password, flow, ...) need
|
// Actions that touch per-client secrets (uuid, password, flow, ...) need
|
||||||
// the full payload that the slim list view does not ship. Hydrate first
|
// the full payload that the slim list view does not ship. Hydrate first
|
||||||
// and then operate on the rehydrated record.
|
// and then operate on the rehydrated record.
|
||||||
|
|
@ -457,21 +461,21 @@ export default function InboundsPage() {
|
||||||
<Card size="small" hoverable className="summary-card">
|
<Card size="small" hoverable className="summary-card">
|
||||||
<Row gutter={[16, 12]}>
|
<Row gutter={[16, 12]}>
|
||||||
<Col xs={12} sm={12} md={8}>
|
<Col xs={12} sm={12} md={8}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.inbounds.totalDownUp')}
|
title={t('pages.inbounds.totalDownUp')}
|
||||||
value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
|
value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
|
||||||
prefix={<SwapOutlined />}
|
prefix={<SwapOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={12} md={8}>
|
<Col xs={12} sm={12} md={8}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.inbounds.totalUsage')}
|
title={t('pages.inbounds.totalUsage')}
|
||||||
value={SizeFormatter.sizeFormat(totals.up + totals.down)}
|
value={SizeFormatter.sizeFormat(totals.up + totals.down)}
|
||||||
prefix={<PieChartOutlined />}
|
prefix={<PieChartOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={24} md={8}>
|
<Col xs={24} sm={24} md={8}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.inbounds.inboundCount')}
|
title={t('pages.inbounds.inboundCount')}
|
||||||
value={String(dbInbounds.length)}
|
value={String(dbInbounds.length)}
|
||||||
prefix={<BarsOutlined />}
|
prefix={<BarsOutlined />}
|
||||||
|
|
@ -483,7 +487,7 @@ export default function InboundsPage() {
|
||||||
|
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<InboundList
|
<InboundList
|
||||||
dbInbounds={dbInbounds as any}
|
dbInbounds={dbInbounds}
|
||||||
clientCount={clientCount}
|
clientCount={clientCount}
|
||||||
onlineClients={onlineClients}
|
onlineClients={onlineClients}
|
||||||
lastOnlineMap={lastOnlineMap}
|
lastOnlineMap={lastOnlineMap}
|
||||||
|
|
@ -496,7 +500,7 @@ export default function InboundsPage() {
|
||||||
hasActiveNode={showNodeInfo}
|
hasActiveNode={showNodeInfo}
|
||||||
onAddInbound={onAddInbound}
|
onAddInbound={onAddInbound}
|
||||||
onGeneralAction={onGeneralAction}
|
onGeneralAction={onGeneralAction}
|
||||||
onRowAction={onRowAction}
|
onRowAction={({ key, dbInbound }) => onRowAction({ key, dbInbound: dbInbound as unknown as DBInbound })}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -512,7 +516,7 @@ export default function InboundsPage() {
|
||||||
onSaved={refresh}
|
onSaved={refresh}
|
||||||
mode={formMode}
|
mode={formMode}
|
||||||
dbInbound={formDbInbound}
|
dbInbound={formDbInbound}
|
||||||
dbInbounds={dbInbounds as any[]}
|
dbInbounds={dbInbounds}
|
||||||
availableNodes={nodesList}
|
availableNodes={nodesList}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { Collapse, Modal } from 'antd';
|
import { Collapse, Modal } from 'antd';
|
||||||
import type { CollapseProps } from 'antd';
|
import type { CollapseProps } from 'antd';
|
||||||
|
|
||||||
import { Protocols } from '@/models/inbound.js';
|
import { Protocols } from '@/models/inbound';
|
||||||
import QrPanel from './QrPanel';
|
import QrPanel from './QrPanel';
|
||||||
import type { SubSettings } from './useInbounds';
|
import type { SubSettings } from './useInbounds';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.qr-panel {
|
.qr-panel {
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
import { DBInbound } from '@/models/dbinbound.js';
|
import { DBInbound } from '@/models/dbinbound';
|
||||||
import { Protocols } from '@/models/inbound.js';
|
import { Protocols } from '@/models/inbound';
|
||||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,22 @@
|
||||||
.backup-list {
|
.backup-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .backup-list,
|
|
||||||
html[data-theme='ultra-dark'] .backup-list {
|
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-item {
|
.backup-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-item:last-child {
|
.backup-item:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .backup-item,
|
|
||||||
html[data-theme='ultra-dark'] .backup-item {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-meta {
|
.backup-meta {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -37,21 +27,11 @@ html[data-theme='ultra-dark'] .backup-item {
|
||||||
.backup-title {
|
.backup-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(0, 0, 0, 0.88);
|
color: var(--ant-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-description {
|
.backup-description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(0, 0, 0, 0.45);
|
color: var(--ant-color-text-tertiary);
|
||||||
line-height: 1.5715;
|
line-height: 1.5715;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .backup-title,
|
|
||||||
html[data-theme='ultra-dark'] .backup-title {
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .backup-description,
|
|
||||||
html[data-theme='ultra-dark'] .backup-description {
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
.mb-10 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -14,15 +10,11 @@
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--ant-color-fill-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .custom-geo-count {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-geo-alias-cell {
|
.custom-geo-alias-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -48,20 +40,12 @@ body.dark .custom-geo-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--ant-color-fill-tertiary);
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-geo-copyable:hover {
|
.custom-geo-copyable:hover {
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: var(--ant-color-fill-secondary);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .custom-geo-ext-code {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .custom-geo-copyable:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-geo-muted {
|
.custom-geo-muted {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,3 @@
|
||||||
.index-page {
|
|
||||||
--bg-page: #e6e8ec;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page.is-dark {
|
|
||||||
--bg-page: #1a1b1f;
|
|
||||||
--bg-card: #23252b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page.is-dark.is-ultra {
|
|
||||||
--bg-page: #000;
|
|
||||||
--bg-card: #101013;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-layout,
|
|
||||||
.index-page .ant-layout-content {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .content-shell {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .content-area {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.index-page .content-area {
|
.index-page .content-area {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
@ -36,156 +5,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .loading-spacer {
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.25s ease, border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card {
|
|
||||||
border-color: rgba(255, 255, 255, 0.06);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0, 0, 0, 0.4),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card {
|
|
||||||
border-color: rgba(255, 255, 255, 0.04);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(0, 0, 0, 0.6),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card.ant-card-hoverable:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: rgba(0, 0, 0, 0.10);
|
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card.ant-card-hoverable:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
box-shadow:
|
|
||||||
0 8px 24px rgba(0, 0, 0, 0.5),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card.ant-card-hoverable:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
|
||||||
box-shadow:
|
|
||||||
0 8px 24px rgba(0, 0, 0, 0.75),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-head {
|
|
||||||
min-height: 44px;
|
|
||||||
padding-inline: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-head-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-body {
|
|
||||||
padding: 18px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-body > .ant-row > .ant-col {
|
|
||||||
position: relative;
|
|
||||||
padding: 4px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.index-page .ant-card .ant-card-body > .ant-row > .ant-col + .ant-col::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 10%;
|
|
||||||
bottom: 10%;
|
|
||||||
width: 1px;
|
|
||||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.10), transparent);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card .ant-card-body > .ant-row > .ant-col + .ant-col::before {
|
|
||||||
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-head {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-actions {
|
|
||||||
border-top-color: rgba(0, 0, 0, 0.06);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-actions > li {
|
|
||||||
border-inline-end-color: rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card .ant-card-head {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card .ant-card-actions {
|
|
||||||
border-top-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card .ant-card-actions > li {
|
|
||||||
border-inline-end-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-head {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-actions {
|
|
||||||
border-top-color: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-actions > li {
|
|
||||||
border-inline-end-color: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .action {
|
.index-page .action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0 8px;
|
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
color: rgba(0, 0, 0, 0.78);
|
|
||||||
font-weight: 500;
|
|
||||||
transition: opacity 0.15s ease, transform 0.15s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .action .anticon {
|
|
||||||
color: rgba(0, 0, 0, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .action {
|
|
||||||
color: rgba(255, 255, 255, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .action .anticon {
|
|
||||||
color: rgba(255, 255, 255, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .action {
|
|
||||||
color: rgba(255, 255, 255, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .action .anticon {
|
|
||||||
color: rgba(255, 255, 255, 0.78);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action > span:not(.anticon):not(.tg-icon) {
|
.index-page .action > span:not(.anticon):not(.tg-icon) {
|
||||||
|
|
@ -195,23 +19,13 @@ html[data-theme='ultra-dark'] .index-page .action .anticon {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action:hover {
|
|
||||||
opacity: 0.75;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card-actions > li {
|
|
||||||
margin: 8px 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .action-update {
|
.index-page .action-update {
|
||||||
color: #fa8c16;
|
color: var(--ant-color-warning);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .action-update .anticon {
|
.index-page .action-update .anticon {
|
||||||
color: #fa8c16;
|
color: var(--ant-color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .history-tag {
|
.index-page .history-tag {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
Statistic,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
|
@ -39,7 +40,6 @@ import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import AppSidebar from '@/components/AppSidebar';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import CustomStatistic from '@/components/CustomStatistic';
|
|
||||||
import LazyMount from '@/components/LazyMount';
|
import LazyMount from '@/components/LazyMount';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import StatusCard from './StatusCard';
|
import StatusCard from './StatusCard';
|
||||||
|
|
@ -53,7 +53,6 @@ const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
|
||||||
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
|
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
|
||||||
const XrayLogModal = lazy(() => import('./XrayLogModal'));
|
const XrayLogModal = lazy(() => import('./XrayLogModal'));
|
||||||
const VersionModal = lazy(() => import('./VersionModal'));
|
const VersionModal = lazy(() => import('./VersionModal'));
|
||||||
import '@/styles/page-cards.css';
|
|
||||||
import './IndexPage.css';
|
import './IndexPage.css';
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
|
|
@ -285,14 +284,14 @@ export default function IndexPage() {
|
||||||
<Card title={t('pages.index.operationHours')} hoverable>
|
<Card title={t('pages.index.operationHours')} hoverable>
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title="Xray"
|
title="Xray"
|
||||||
value={TimeFormatter.formatSecond(status.appStats.uptime)}
|
value={TimeFormatter.formatSecond(status.appStats.uptime)}
|
||||||
prefix={<ThunderboltOutlined />}
|
prefix={<ThunderboltOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title="OS"
|
title="OS"
|
||||||
value={TimeFormatter.formatSecond(status.uptime)}
|
value={TimeFormatter.formatSecond(status.uptime)}
|
||||||
prefix={<DesktopOutlined />}
|
prefix={<DesktopOutlined />}
|
||||||
|
|
@ -306,14 +305,14 @@ export default function IndexPage() {
|
||||||
<Card title={t('usage')} hoverable>
|
<Card title={t('usage')} hoverable>
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.index.memory')}
|
title={t('pages.index.memory')}
|
||||||
value={SizeFormatter.sizeFormat(status.appStats.mem)}
|
value={SizeFormatter.sizeFormat(status.appStats.mem)}
|
||||||
prefix={<DatabaseOutlined />}
|
prefix={<DatabaseOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.index.threads')}
|
title={t('pages.index.threads')}
|
||||||
value={status.appStats.threads}
|
value={status.appStats.threads}
|
||||||
prefix={<ForkOutlined />}
|
prefix={<ForkOutlined />}
|
||||||
|
|
@ -327,7 +326,7 @@ export default function IndexPage() {
|
||||||
<Card title={t('pages.index.overallSpeed')} hoverable>
|
<Card title={t('pages.index.overallSpeed')} hoverable>
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.index.upload')}
|
title={t('pages.index.upload')}
|
||||||
value={SizeFormatter.sizeFormat(status.netIO.up)}
|
value={SizeFormatter.sizeFormat(status.netIO.up)}
|
||||||
prefix={<ArrowUpOutlined />}
|
prefix={<ArrowUpOutlined />}
|
||||||
|
|
@ -335,7 +334,7 @@ export default function IndexPage() {
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.index.download')}
|
title={t('pages.index.download')}
|
||||||
value={SizeFormatter.sizeFormat(status.netIO.down)}
|
value={SizeFormatter.sizeFormat(status.netIO.down)}
|
||||||
prefix={<ArrowDownOutlined />}
|
prefix={<ArrowDownOutlined />}
|
||||||
|
|
@ -350,14 +349,14 @@ export default function IndexPage() {
|
||||||
<Card title={t('pages.index.totalData')} hoverable>
|
<Card title={t('pages.index.totalData')} hoverable>
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.index.sent')}
|
title={t('pages.index.sent')}
|
||||||
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
|
value={SizeFormatter.sizeFormat(status.netTraffic.sent)}
|
||||||
prefix={<CloudUploadOutlined />}
|
prefix={<CloudUploadOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.index.received')}
|
title={t('pages.index.received')}
|
||||||
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
|
value={SizeFormatter.sizeFormat(status.netTraffic.recv)}
|
||||||
prefix={<CloudDownloadOutlined />}
|
prefix={<CloudDownloadOutlined />}
|
||||||
|
|
@ -392,14 +391,14 @@ export default function IndexPage() {
|
||||||
>
|
>
|
||||||
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
|
<Row className={showIp ? 'ip-visible' : 'ip-hidden'} gutter={isMobile ? [8, 8] : 0}>
|
||||||
<Col span={isMobile ? 24 : 12}>
|
<Col span={isMobile ? 24 : 12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title="IPv4"
|
title="IPv4"
|
||||||
value={status.publicIP.ipv4}
|
value={status.publicIP.ipv4}
|
||||||
prefix={<GlobalOutlined />}
|
prefix={<GlobalOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={isMobile ? 24 : 12}>
|
<Col span={isMobile ? 24 : 12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title="IPv6"
|
title="IPv6"
|
||||||
value={status.publicIP.ipv6}
|
value={status.publicIP.ipv6}
|
||||||
prefix={<GlobalOutlined />}
|
prefix={<GlobalOutlined />}
|
||||||
|
|
@ -413,14 +412,14 @@ export default function IndexPage() {
|
||||||
<Card title={t('pages.index.connectionCount')} hoverable>
|
<Card title={t('pages.index.connectionCount')} hoverable>
|
||||||
<Row gutter={isMobile ? [8, 8] : 0}>
|
<Row gutter={isMobile ? [8, 8] : 0}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title="TCP"
|
title="TCP"
|
||||||
value={status.tcpCount}
|
value={status.tcpCount}
|
||||||
prefix={<SwapOutlined />}
|
prefix={<SwapOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title="UDP"
|
title="UDP"
|
||||||
value={status.udpCount}
|
value={status.udpCount}
|
||||||
prefix={<SwapOutlined />}
|
prefix={<SwapOutlined />}
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,10 @@
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
border: 1px solid var(--ant-color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--ant-color-fill-tertiary);
|
||||||
|
color: var(--ant-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-stamp {
|
.log-stamp {
|
||||||
|
|
@ -140,10 +141,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .log-container {
|
body.dark .log-container {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
color: rgba(255, 255, 255, 0.88);
|
|
||||||
|
|
||||||
--log-stamp: #6aa6ee;
|
--log-stamp: #6aa6ee;
|
||||||
--log-debug: #6aa6ee;
|
--log-debug: #6aa6ee;
|
||||||
--log-info: #4ed3a6;
|
--log-info: #4ed3a6;
|
||||||
|
|
@ -165,12 +162,6 @@ html[data-theme="ultra-dark"] .log-container {
|
||||||
--log-divider: rgba(255, 255, 255, 0.12);
|
--log-divider: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logmodal-mobile {
|
|
||||||
top: 0 !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
max-width: 100vw !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logmodal-mobile .ant-modal-content {
|
.logmodal-mobile .ant-modal-content {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
|
||||||
open={open}
|
open={open}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={isMobile ? '100vw' : 800}
|
width={isMobile ? '100vw' : 800}
|
||||||
|
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||||
className={isMobile ? 'logmodal-mobile' : undefined}
|
className={isMobile ? 'logmodal-mobile' : undefined}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
title={titleNode}
|
title={titleNode}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,22 @@
|
||||||
.mb-12 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list {
|
.version-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .version-list,
|
|
||||||
html[data-theme='ultra-dark'] .version-list {
|
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list-item {
|
.version-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-list-item:last-child {
|
.version-list-item:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .version-list-item,
|
|
||||||
html[data-theme='ultra-dark'] .version-list-item {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row {
|
.actions-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,9 @@
|
||||||
margin: 8px 8px 16px;
|
margin: 8px 8px 16px;
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(180deg, rgba(99, 102, 241, 0.05), rgba(99, 102, 241, 0));
|
background: linear-gradient(180deg, color-mix(in srgb, var(--ant-color-primary) 6%, transparent), transparent);
|
||||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.06);
|
box-shadow: 0 2px 12px var(--ant-color-fill-quaternary);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .cpu-chart-wrap {
|
|
||||||
background: linear-gradient(180deg, rgba(129, 140, 248, 0.08), rgba(129, 140, 248, 0));
|
|
||||||
border-color: rgba(129, 140, 248, 0.16);
|
|
||||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .cpu-chart-wrap {
|
|
||||||
background: linear-gradient(180deg, rgba(129, 140, 248, 0.05), rgba(129, 140, 248, 0));
|
|
||||||
border-color: rgba(129, 140, 248, 0.10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cpu-chart-meta {
|
.cpu-chart-meta {
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,6 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
|
||||||
<Sparkline
|
<Sparkline
|
||||||
data={points}
|
data={points}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
vbWidth={840}
|
|
||||||
height={220}
|
height={220}
|
||||||
stroke={strokeColor}
|
stroke={strokeColor}
|
||||||
strokeWidth={2.2}
|
strokeWidth={2.2}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,22 @@
|
||||||
.mb-12 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list {
|
.version-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .version-list,
|
|
||||||
html[data-theme='ultra-dark'] .version-list {
|
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-list-item {
|
.version-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-list-item:last-child {
|
.version-list-item:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .version-list-item,
|
|
||||||
html[data-theme='ultra-dark'] .version-list-item {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reload-icon {
|
.reload-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
border: 1px solid var(--ant-color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--ant-color-fill-tertiary);
|
||||||
|
color: var(--ant-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-container-mobile {
|
.log-container-mobile {
|
||||||
|
|
@ -110,10 +111,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .log-container {
|
body.dark .log-container {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
color: rgba(255, 255, 255, 0.88);
|
|
||||||
|
|
||||||
--log-blocked: #ff7575;
|
--log-blocked: #ff7575;
|
||||||
--log-proxy: #6aa6ee;
|
--log-proxy: #6aa6ee;
|
||||||
--log-divider: rgba(255, 255, 255, 0.1);
|
--log-divider: rgba(255, 255, 255, 0.1);
|
||||||
|
|
@ -125,12 +122,6 @@ html[data-theme="ultra-dark"] .log-container {
|
||||||
--log-divider: rgba(255, 255, 255, 0.12);
|
--log-divider: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.xraylog-modal-mobile {
|
|
||||||
top: 0 !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
max-width: 100vw !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xraylog-modal-mobile .ant-modal-content {
|
.xraylog-modal-mobile .ant-modal-content {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||||
open={open}
|
open={open}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={isMobile ? '100vw' : '80vw'}
|
width={isMobile ? '100vw' : '80vw'}
|
||||||
|
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||||
className={isMobile ? 'xraylog-modal-mobile' : undefined}
|
className={isMobile ? 'xraylog-modal-mobile' : undefined}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
title={
|
title={
|
||||||
|
|
|
||||||
|
|
@ -40,23 +40,23 @@
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.18);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-dot.is-alive {
|
.obs-dot.is-alive {
|
||||||
background: #52c41a;
|
background: var(--ant-color-success);
|
||||||
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.22);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
|
||||||
animation: obs-dot-pulse 2.2s ease-in-out infinite;
|
animation: obs-dot-pulse 2.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obs-dot.is-dead {
|
.obs-dot.is-dead {
|
||||||
background: #f5222d;
|
background: var(--ant-color-error);
|
||||||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.22);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-error) 22%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes obs-dot-pulse {
|
@keyframes obs-dot-pulse {
|
||||||
0%, 100% { box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.22); }
|
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent); }
|
||||||
50% { box-shadow: 0 0 0 6px rgba(82, 196, 26, 0.06); }
|
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ant-color-success) 6%, transparent); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,6 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
|
||||||
<Sparkline
|
<Sparkline
|
||||||
data={points}
|
data={points}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
vbWidth={840}
|
|
||||||
height={220}
|
height={220}
|
||||||
stroke={strokeColor}
|
stroke={strokeColor}
|
||||||
strokeWidth={2.2}
|
strokeWidth={2.2}
|
||||||
|
|
|
||||||
|
|
@ -12,33 +12,3 @@
|
||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xray-processing-animation .ant-badge-status-dot {
|
|
||||||
animation: xray-pulse 1.2s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-running-animation .ant-badge-status-processing::after {
|
|
||||||
border-color: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-stop-animation .ant-badge-status-processing::after {
|
|
||||||
border-color: #fa8c16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-error-animation .ant-badge-status-processing::after {
|
|
||||||
border-color: #f5222d;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes xray-pulse {
|
|
||||||
0%,
|
|
||||||
50%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
10% {
|
|
||||||
transform: scale(1.5);
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,6 @@ const XRAY_STATE_KEYS: Record<string, string> = {
|
||||||
error: 'pages.index.xrayStatusError',
|
error: 'pages.index.xrayStatusError',
|
||||||
};
|
};
|
||||||
|
|
||||||
function badgeAnimationClass(color: string): string {
|
|
||||||
if (color === 'green') return 'xray-running-animation';
|
|
||||||
if (color === 'orange') return 'xray-stop-animation';
|
|
||||||
if (color === 'red') return 'xray-error-animation';
|
|
||||||
return 'xray-processing-animation';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function XrayStatusCard({
|
export default function XrayStatusCard({
|
||||||
status,
|
status,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
|
@ -65,12 +58,7 @@ export default function XrayStatusCard({
|
||||||
|
|
||||||
const extra =
|
const extra =
|
||||||
status.xray.state !== 'error' ? (
|
status.xray.state !== 'error' ? (
|
||||||
<Badge
|
<Badge status="processing" text={stateText} color={status.xray.color} />
|
||||||
status="processing"
|
|
||||||
className={`xray-processing-animation ${badgeAnimationClass(status.xray.color)}`}
|
|
||||||
text={stateText}
|
|
||||||
color={status.xray.color}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Popover
|
<Popover
|
||||||
title={
|
title={
|
||||||
|
|
@ -93,12 +81,7 @@ export default function XrayStatusCard({
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge status="processing" text={stateText} color={status.xray.color} />
|
||||||
status="processing"
|
|
||||||
text={stateText}
|
|
||||||
color={status.xray.color}
|
|
||||||
className="xray-processing-animation xray-error-animation"
|
|
||||||
/>
|
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,36 +228,6 @@
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-cycle {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--color-text);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-cycle:hover,
|
|
||||||
.theme-cycle:focus-visible {
|
|
||||||
background-color: rgba(99, 102, 241, 0.15);
|
|
||||||
color: var(--color-accent);
|
|
||||||
transform: scale(1.05);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-cycle svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-wrapper {
|
.login-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|
@ -402,44 +372,3 @@
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
min-width: 160px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
text-align: start;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-item:hover,
|
|
||||||
.lang-item:focus-visible {
|
|
||||||
background-color: rgba(99, 102, 241, 0.12);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-item.is-active {
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-item-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,18 @@ import {
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Layout,
|
Layout,
|
||||||
|
Menu,
|
||||||
Popover,
|
Popover,
|
||||||
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
|
MoonFilled,
|
||||||
|
MoonOutlined,
|
||||||
|
SunOutlined,
|
||||||
TranslationOutlined,
|
TranslationOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
@ -105,26 +110,20 @@ export default function LoginPage() {
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
}, [isDark, isUltra]);
|
}, [isDark, isUltra]);
|
||||||
|
|
||||||
const langList = useMemo(
|
const langMenuItems = useMemo(
|
||||||
() => LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[],
|
() => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
|
||||||
|
key: l.value,
|
||||||
|
label: (
|
||||||
|
<Space size={8}>
|
||||||
|
<span aria-hidden="true">{l.icon}</span>
|
||||||
|
<span>{l.name}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
})),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const themeIcon = !isDark ? (
|
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
|
@ -132,35 +131,30 @@ export default function LoginPage() {
|
||||||
<Layout className={pageClass}>
|
<Layout className={pageClass}>
|
||||||
<Layout.Content className="login-content">
|
<Layout.Content className="login-content">
|
||||||
<div className="login-toolbar">
|
<div className="login-toolbar">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
id="login-theme-cycle"
|
id="login-theme-cycle"
|
||||||
className="theme-cycle"
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
className="toolbar-btn"
|
||||||
aria-label={t('menu.theme')}
|
aria-label={t('menu.theme')}
|
||||||
title={t('menu.theme')}
|
title={t('menu.theme')}
|
||||||
|
icon={themeIcon}
|
||||||
onClick={cycleTheme}
|
onClick={cycleTheme}
|
||||||
>
|
/>
|
||||||
{themeIcon}
|
|
||||||
</button>
|
|
||||||
<Popover
|
<Popover
|
||||||
rootClassName={isDark ? 'dark' : 'light'}
|
rootClassName={isDark ? 'dark' : 'light'}
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
|
styles={{ content: { padding: 4 } }}
|
||||||
content={
|
content={
|
||||||
<ul className="lang-list">
|
<Menu
|
||||||
{langList.map((l) => (
|
mode="vertical"
|
||||||
<li key={l.value}>
|
selectable
|
||||||
<button
|
selectedKeys={[lang]}
|
||||||
type="button"
|
items={langMenuItems}
|
||||||
className={`lang-item${lang === l.value ? ' is-active' : ''}`}
|
onClick={({ key }) => onLangChange(key)}
|
||||||
onClick={() => onLangChange(l.value)}
|
style={{ border: 'none', minWidth: 160 }}
|
||||||
>
|
/>
|
||||||
<span className="lang-item-icon" aria-hidden="true">{l.icon}</span>
|
|
||||||
<span className="lang-item-name">{l.name}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,6 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
||||||
<Sparkline
|
<Sparkline
|
||||||
data={cpuPoints}
|
data={cpuPoints}
|
||||||
labels={cpuLabels}
|
labels={cpuLabels}
|
||||||
vbWidth={640}
|
|
||||||
height={120}
|
height={120}
|
||||||
stroke="#008771"
|
stroke="#008771"
|
||||||
showGrid
|
showGrid
|
||||||
|
|
@ -108,7 +107,6 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
||||||
<Sparkline
|
<Sparkline
|
||||||
data={memPoints}
|
data={memPoints}
|
||||||
labels={memLabels}
|
labels={memLabels}
|
||||||
vbWidth={640}
|
|
||||||
height={120}
|
height={120}
|
||||||
stroke="#7c4dff"
|
stroke="#7c4dff"
|
||||||
showGrid
|
showGrid
|
||||||
|
|
|
||||||
|
|
@ -52,20 +52,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-card {
|
.node-card {
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--ant-color-fill-quaternary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .node-card {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-head {
|
.card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -135,7 +130,7 @@ body.dark .node-card {
|
||||||
.card-history {
|
.card-history {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
border-top: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-empty {
|
.card-empty {
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ export default function NodeList({
|
||||||
<span>{t(`pages.nodes.statusValues.${record.status || 'unknown'}`)}</span>
|
<span>{t(`pages.nodes.statusValues.${record.status || 'unknown'}`)}</span>
|
||||||
{record.lastError && (
|
{record.lastError && (
|
||||||
<Tooltip title={record.lastError}>
|
<Tooltip title={record.lastError}>
|
||||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
@ -378,7 +378,7 @@ export default function NodeList({
|
||||||
<span>{t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)}</span>
|
<span>{t(`pages.nodes.statusValues.${statsNode.status || 'unknown'}`)}</span>
|
||||||
{statsNode.lastError && (
|
{statsNode.lastError && (
|
||||||
<Tooltip title={statsNode.lastError}>
|
<Tooltip title={statsNode.lastError}>
|
||||||
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
.nodes-page {
|
|
||||||
--bg-page: #e6e8ec;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodes-page.is-dark {
|
|
||||||
--bg-page: #1a1b1f;
|
|
||||||
--bg-card: #23252b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodes-page.is-dark.is-ultra {
|
|
||||||
--bg-page: #000;
|
|
||||||
--bg-card: #101013;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodes-page .ant-layout,
|
|
||||||
.nodes-page .ant-layout-content {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodes-page .content-shell {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodes-page .content-area {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.nodes-page .content-area {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodes-page .loading-spacer {
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nodes-page .summary-card {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.nodes-page .summary-card {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd';
|
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, Statistic, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
|
|
@ -14,12 +14,9 @@ import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
import { useNodeMutations } from '@/api/queries/useNodeMutations';
|
import { useNodeMutations } from '@/api/queries/useNodeMutations';
|
||||||
import AppSidebar from '@/components/AppSidebar';
|
import AppSidebar from '@/components/AppSidebar';
|
||||||
import CustomStatistic from '@/components/CustomStatistic';
|
|
||||||
import NodeList from './NodeList';
|
import NodeList from './NodeList';
|
||||||
import NodeFormModal from './NodeFormModal';
|
import NodeFormModal from './NodeFormModal';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import '@/styles/page-cards.css';
|
|
||||||
import './NodesPage.css';
|
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -109,28 +106,28 @@ export default function NodesPage() {
|
||||||
<Card size="small" hoverable className="summary-card">
|
<Card size="small" hoverable className="summary-card">
|
||||||
<Row gutter={[16, isMobile ? 16 : 12]}>
|
<Row gutter={[16, isMobile ? 16 : 12]}>
|
||||||
<Col xs={12} sm={12} md={6}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.nodes.totalNodes')}
|
title={t('pages.nodes.totalNodes')}
|
||||||
value={String(totals.total)}
|
value={String(totals.total)}
|
||||||
prefix={<CloudServerOutlined />}
|
prefix={<CloudServerOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={12} md={6}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.nodes.onlineNodes')}
|
title={t('pages.nodes.onlineNodes')}
|
||||||
value={String(totals.online)}
|
value={String(totals.online)}
|
||||||
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
prefix={<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={12} md={6}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.nodes.offlineNodes')}
|
title={t('pages.nodes.offlineNodes')}
|
||||||
value={String(totals.offline)}
|
value={String(totals.offline)}
|
||||||
prefix={<CloseCircleOutlined style={{ color: '#ff4d4f' }} />}
|
prefix={<CloseCircleOutlined style={{ color: 'var(--ant-color-error)' }} />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={12} md={6}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<CustomStatistic
|
<Statistic
|
||||||
title={t('pages.nodes.avgLatency')}
|
title={t('pages.nodes.avgLatency')}
|
||||||
value={totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'}
|
value={totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'}
|
||||||
prefix={<ThunderboltOutlined />}
|
prefix={<ThunderboltOutlined />}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-token-row {
|
.api-token-row {
|
||||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(128, 128, 128, 0.08);
|
background: var(--ant-color-fill-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,9 @@
|
||||||
.settings-page {
|
|
||||||
--bg-page: #e6e8ec;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page.is-dark {
|
|
||||||
--bg-page: #1a1b1f;
|
|
||||||
--bg-card: #23252b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page.is-dark.is-ultra {
|
|
||||||
--bg-page: #000;
|
|
||||||
--bg-card: #101013;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page .ant-layout,
|
|
||||||
.settings-page .ant-layout-content {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page .content-shell {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page .content-area {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page .loading-spacer {
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page .conf-alert {
|
.settings-page .conf-alert {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-page .header-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page .header-actions {
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-page .header-info {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icons-only .ant-tabs-nav {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icons-only .ant-tabs-nav-wrap {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icons-only .ant-tabs-nav-list {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icons-only .ant-tabs-tab {
|
|
||||||
flex: 1 1 0;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icons-only .ant-tabs-tab .anticon {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icons-only .ant-tabs-nav-operations {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldap-no-inbounds {
|
.ldap-no-inbounds {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: #999;
|
color: var(--ant-color-text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import SecurityTab from './SecurityTab';
|
||||||
import TelegramTab from './TelegramTab';
|
import TelegramTab from './TelegramTab';
|
||||||
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
||||||
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
||||||
import '@/styles/page-cards.css';
|
|
||||||
import './SettingsPage.css';
|
import './SettingsPage.css';
|
||||||
|
|
||||||
interface ApiMsg {
|
interface ApiMsg {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
.nested-block {
|
.nested-block {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
display: block !important;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@
|
||||||
|
|
||||||
.qr-code {
|
.qr-code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 !important;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-token {
|
.qr-token {
|
||||||
|
|
|
||||||
|
|
@ -53,49 +53,12 @@
|
||||||
|
|
||||||
.qr-code {
|
.qr-code {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 !important;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-table {
|
.info-table {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-table .ant-descriptions-view,
|
|
||||||
.info-table .ant-descriptions-view table,
|
|
||||||
.info-table .ant-descriptions-view th,
|
|
||||||
.info-table .ant-descriptions-view td {
|
|
||||||
border-color: rgba(0, 0, 0, 0.18) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-table tbody > tr > th,
|
|
||||||
.info-table tbody > tr > td {
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.18) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-table tbody > tr:last-child > th,
|
|
||||||
.info-table tbody > tr:last-child > td {
|
|
||||||
border-bottom: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-dark .info-table .ant-descriptions-view,
|
|
||||||
.is-dark .info-table .ant-descriptions-view table,
|
|
||||||
.is-dark .info-table .ant-descriptions-view th,
|
|
||||||
.is-dark .info-table .ant-descriptions-view td {
|
|
||||||
border-color: rgba(255, 255, 255, 0.18) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-dark .info-table tbody > tr > th,
|
|
||||||
.is-dark .info-table tbody > tr > td {
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-dark .info-table tbody > tr:last-child > th,
|
|
||||||
.is-dark .info-table tbody > tr:last-child > td {
|
|
||||||
border-bottom: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links-section {
|
.links-section {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
@ -158,49 +121,15 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-popover {
|
.toolbar-btn {
|
||||||
min-width: 220px;
|
width: 40px;
|
||||||
}
|
height: 40px;
|
||||||
|
min-width: 40px;
|
||||||
.theme-cycle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-cycle:hover,
|
.toolbar-btn .anticon {
|
||||||
.theme-cycle:focus-visible {
|
font-size: 18px;
|
||||||
background-color: rgba(64, 150, 255, 0.1);
|
|
||||||
color: #4096ff;
|
|
||||||
transform: scale(1.05);
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-cycle svg {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-dark .theme-cycle {
|
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-dark .theme-cycle:hover,
|
|
||||||
.is-dark .theme-cycle:focus-visible {
|
|
||||||
background-color: rgba(64, 150, 255, 0.1);
|
|
||||||
color: #4096ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import {
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Layout,
|
Layout,
|
||||||
|
Menu,
|
||||||
message,
|
message,
|
||||||
Popover,
|
Popover,
|
||||||
QRCode,
|
QRCode,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
|
@ -21,7 +21,10 @@ import {
|
||||||
AppleOutlined,
|
AppleOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
SettingOutlined,
|
MoonFilled,
|
||||||
|
MoonOutlined,
|
||||||
|
SunOutlined,
|
||||||
|
TranslationOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||||
|
|
@ -206,34 +209,20 @@ export default function SubPage() {
|
||||||
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
|
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
|
||||||
], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl]);
|
], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl]);
|
||||||
|
|
||||||
const langOptions = useMemo(
|
const langMenuItems = useMemo(
|
||||||
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
|
() => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
|
||||||
value: l.value,
|
key: l.value,
|
||||||
label: (
|
label: (
|
||||||
<>
|
<Space size={8}>
|
||||||
<span aria-label={l.name}>{l.icon}</span>
|
<span aria-hidden="true">{l.icon}</span>
|
||||||
<span>{l.name}</span>
|
<span>{l.name}</span>
|
||||||
</>
|
</Space>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const themeIcon = !isDark ? (
|
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const cardTitle = (
|
const cardTitle = (
|
||||||
<Space>
|
<Space>
|
||||||
|
|
@ -244,32 +233,38 @@ export default function SubPage() {
|
||||||
|
|
||||||
const cardExtra = (
|
const cardExtra = (
|
||||||
<Space size={8} align="center">
|
<Space size={8} align="center">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
shape="circle"
|
||||||
id="sub-theme-cycle"
|
size="large"
|
||||||
className="theme-cycle"
|
className="toolbar-btn"
|
||||||
aria-label={t('menu.theme')}
|
aria-label={t('menu.theme')}
|
||||||
title={t('menu.theme')}
|
title={t('menu.theme')}
|
||||||
|
icon={themeIcon}
|
||||||
onClick={cycleTheme}
|
onClick={cycleTheme}
|
||||||
>
|
/>
|
||||||
{themeIcon}
|
|
||||||
</button>
|
|
||||||
<Popover
|
<Popover
|
||||||
title={t('pages.settings.language')}
|
rootClassName={isDark ? 'dark' : 'light'}
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
|
styles={{ content: { padding: 4 } }}
|
||||||
content={
|
content={
|
||||||
<Space orientation="vertical" size={10} className="settings-popover">
|
<Menu
|
||||||
<Select
|
mode="vertical"
|
||||||
className="lang-select"
|
selectable
|
||||||
value={lang}
|
selectedKeys={[lang]}
|
||||||
onChange={onLangChange}
|
items={langMenuItems}
|
||||||
options={langOptions}
|
onClick={({ key }) => onLangChange(key)}
|
||||||
|
style={{ border: 'none', minWidth: 160 }}
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button shape="circle" icon={<SettingOutlined />} />
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
className="toolbar-btn"
|
||||||
|
aria-label={t('pages.settings.language')}
|
||||||
|
icon={<TranslationOutlined />}
|
||||||
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
.mb-12 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-alert {
|
.hint-alert {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'antd';
|
import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'antd';
|
||||||
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons';
|
import { CloudOutlined, ApiOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { OutboundDomainStrategies } from '@/models/outbound.js';
|
import { OutboundDomainStrategies } from '@/models/outbound';
|
||||||
import SettingListItem from '@/components/SettingListItem';
|
import SettingListItem from '@/components/SettingListItem';
|
||||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||||
import './BasicsTab.css';
|
import './BasicsTab.css';
|
||||||
|
|
@ -205,9 +205,9 @@ export default function BasicsTab({
|
||||||
<>
|
<>
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
|
showIcon
|
||||||
className="mb-12 hint-alert"
|
className="mb-12 hint-alert"
|
||||||
title={t('pages.xray.generalConfigsDesc')}
|
title={t('pages.xray.generalConfigsDesc')}
|
||||||
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
|
|
||||||
/>
|
/>
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
title={t('pages.xray.FreedomStrategy')}
|
title={t('pages.xray.FreedomStrategy')}
|
||||||
|
|
@ -299,9 +299,9 @@ export default function BasicsTab({
|
||||||
<>
|
<>
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
|
showIcon
|
||||||
className="mb-12 hint-alert"
|
className="mb-12 hint-alert"
|
||||||
title={t('pages.xray.logConfigsDesc')}
|
title={t('pages.xray.logConfigsDesc')}
|
||||||
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
|
|
||||||
/>
|
/>
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
title={t('pages.xray.logLevel')}
|
title={t('pages.xray.logLevel')}
|
||||||
|
|
@ -376,9 +376,9 @@ export default function BasicsTab({
|
||||||
<>
|
<>
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
|
showIcon
|
||||||
className="mb-12 hint-alert"
|
className="mb-12 hint-alert"
|
||||||
title={t('pages.xray.blockConnectionsConfigsDesc')}
|
title={t('pages.xray.blockConnectionsConfigsDesc')}
|
||||||
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
|
|
@ -427,9 +427,9 @@ export default function BasicsTab({
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
|
showIcon
|
||||||
className="mb-12 hint-alert"
|
className="mb-12 hint-alert"
|
||||||
title={t('pages.xray.directConnectionsConfigsDesc')}
|
title={t('pages.xray.directConnectionsConfigsDesc')}
|
||||||
icon={<ExclamationCircleFilled style={{ color: '#FFA031' }} />}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,22 @@
|
||||||
.preset-list {
|
.preset-list {
|
||||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .preset-list,
|
|
||||||
html[data-theme='ultra-dark'] .preset-list {
|
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-row {
|
.preset-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-row:last-child {
|
.preset-row:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .preset-row,
|
|
||||||
html[data-theme='ultra-dark'] .preset-row {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-name {
|
.preset-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,36 +18,8 @@
|
||||||
width: 130px;
|
width: 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-odd {
|
.nord-data-table .row-odd {
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--ant-color-fill-tertiary);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .row-odd {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.zero-margin {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-8 {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-10 {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-20 {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-10 {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-8 {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-row {
|
.server-row {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,3 @@
|
||||||
.random-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--ant-primary-color, #1890ff);
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #ff4d4f;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-8 {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-8 {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-heading {
|
.item-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import {
|
||||||
Address_Port_Strategy,
|
Address_Port_Strategy,
|
||||||
MODE_OPTION,
|
MODE_OPTION,
|
||||||
DNSRuleActions,
|
DNSRuleActions,
|
||||||
} from '@/models/outbound.js';
|
} from '@/models/outbound';
|
||||||
import FinalMaskForm from '@/components/FinalMaskForm';
|
import FinalMaskForm from '@/components/FinalMaskForm';
|
||||||
import JsonEditor from '@/components/JsonEditor';
|
import JsonEditor from '@/components/JsonEditor';
|
||||||
import './OutboundFormModal.css';
|
import './OutboundFormModal.css';
|
||||||
|
|
@ -469,8 +469,7 @@ export default function OutboundFormModal({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
type OB = Outbound;
|
||||||
type OB = any;
|
|
||||||
|
|
||||||
interface FieldProps {
|
interface FieldProps {
|
||||||
ob: OB;
|
ob: OB;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.outbound-card {
|
.outbound-card {
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
@ -65,11 +65,7 @@
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--ant-color-fill-tertiary);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .address-pill {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-cell {
|
.action-cell {
|
||||||
|
|
@ -181,8 +177,8 @@ body.dark .address-pill {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(22, 119, 255, 0.12);
|
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||||
color: #1677ff;
|
color: var(--ant-color-primary);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import {
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { SizeFormatter } from '@/utils';
|
||||||
import { Protocols } from '@/models/outbound.js';
|
import { Protocols } from '@/models/outbound';
|
||||||
import OutboundFormModal from './OutboundFormModal';
|
import OutboundFormModal from './OutboundFormModal';
|
||||||
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
import './OutboundsTab.css';
|
import './OutboundsTab.css';
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-before > td {
|
.drop-before > td {
|
||||||
box-shadow: inset 0 2px 0 0 #1677ff;
|
box-shadow: inset 0 2px 0 0 var(--ant-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-after > td {
|
.drop-after > td {
|
||||||
box-shadow: inset 0 -2px 0 0 #1677ff;
|
box-shadow: inset 0 -2px 0 0 var(--ant-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-index {
|
.row-index {
|
||||||
|
|
@ -78,11 +78,7 @@
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: var(--ant-color-fill-tertiary);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .criterion-more {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.criterion-empty {
|
.criterion-empty {
|
||||||
|
|
@ -113,7 +109,7 @@ body.dark .criterion-more {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: var(--bg-card, #fff);
|
background: var(--bg-card, #fff);
|
||||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
border: 1px solid var(--ant-color-border-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: opacity 0.15s, box-shadow 0.15s;
|
transition: opacity 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
@ -123,11 +119,11 @@ body.dark .criterion-more {
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-card.drop-before {
|
.rule-card.drop-before {
|
||||||
box-shadow: inset 0 2px 0 0 #1677ff;
|
box-shadow: inset 0 2px 0 0 var(--ant-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-card.drop-after {
|
.rule-card.drop-after {
|
||||||
box-shadow: inset 0 -2px 0 0 #1677ff;
|
box-shadow: inset 0 -2px 0 0 var(--ant-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-card-head {
|
.rule-card-head {
|
||||||
|
|
@ -188,7 +184,7 @@ body.dark .criterion-more {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
border-top: 1px dashed rgba(128, 128, 128, 0.2);
|
border-top: 1px dashed var(--ant-color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.criterion-chip {
|
.criterion-chip {
|
||||||
|
|
@ -197,7 +193,7 @@ body.dark .criterion-more {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: rgba(128, 128, 128, 0.08);
|
background: var(--ant-color-fill-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -222,11 +218,3 @@ body.dark .criterion-more {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .rule-card {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .criterion-chip {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,32 +18,8 @@
|
||||||
width: 130px;
|
width: 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-odd {
|
.warp-data-table .row-odd {
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--ant-color-fill-tertiary);
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .row-odd {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.zero-margin {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-8 {
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-8 {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-10 {
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-8 {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.license-actions {
|
.license-actions {
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,7 @@
|
||||||
.xray-page {
|
|
||||||
--bg-page: #e6e8ec;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page.is-dark {
|
|
||||||
--bg-page: #1a1b1f;
|
|
||||||
--bg-card: #23252b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page.is-dark.is-ultra {
|
|
||||||
--bg-page: #000;
|
|
||||||
--bg-card: #101013;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .ant-layout,
|
|
||||||
.xray-page .ant-layout-content {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .content-shell {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .content-area {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .loading-spacer {
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .header-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .header-actions {
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .header-info {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .restart-icon {
|
.xray-page .restart-icon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--ant-primary-color, #1890ff);
|
color: var(--ant-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.xray-page .restart-result {
|
.xray-page .restart-result {
|
||||||
|
|
@ -69,32 +19,3 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xray-page .icons-only .ant-tabs-nav {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .icons-only .ant-tabs-nav-wrap {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .icons-only .ant-tabs-nav-list {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .icons-only .ant-tabs-tab {
|
|
||||||
flex: 1 1 0;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .icons-only .ant-tabs-tab .anticon {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .icons-only .ant-tabs-nav-operations {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import BalancersTab from './BalancersTab';
|
||||||
import DnsTab from './DnsTab';
|
import DnsTab from './DnsTab';
|
||||||
import WarpModal from './WarpModal';
|
import WarpModal from './WarpModal';
|
||||||
import NordModal from './NordModal';
|
import NordModal from './NordModal';
|
||||||
import '@/styles/page-cards.css';
|
|
||||||
import './XrayPage.css';
|
import './XrayPage.css';
|
||||||
|
|
||||||
const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
|
const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,29 @@
|
||||||
.nodes-page .ant-card,
|
.nodes-page .ant-card,
|
||||||
.api-docs-page .ant-card {
|
.api-docs-page .ant-card {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
transition: transform 0.2s ease, box-shadow 0.25s ease, border-color 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.25s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .index-page .ant-card,
|
.index-page.is-dark .ant-card,
|
||||||
body.dark .clients-page .ant-card,
|
.clients-page.is-dark .ant-card,
|
||||||
body.dark .inbounds-page .ant-card,
|
.inbounds-page.is-dark .ant-card,
|
||||||
body.dark .xray-page .ant-card,
|
.xray-page.is-dark .ant-card,
|
||||||
body.dark .settings-page .ant-card,
|
.settings-page.is-dark .ant-card,
|
||||||
body.dark .nodes-page .ant-card,
|
.nodes-page.is-dark .ant-card,
|
||||||
body.dark .api-docs-page .ant-card {
|
.api-docs-page.is-dark .ant-card {
|
||||||
border-color: rgba(255, 255, 255, 0.06);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 2px rgba(0, 0, 0, 0.4),
|
0 1px 2px rgba(0, 0, 0, 0.4),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card,
|
.index-page.is-dark.is-ultra .ant-card,
|
||||||
html[data-theme='ultra-dark'] .clients-page .ant-card,
|
.clients-page.is-dark.is-ultra .ant-card,
|
||||||
html[data-theme='ultra-dark'] .inbounds-page .ant-card,
|
.inbounds-page.is-dark.is-ultra .ant-card,
|
||||||
html[data-theme='ultra-dark'] .xray-page .ant-card,
|
.xray-page.is-dark.is-ultra .ant-card,
|
||||||
html[data-theme='ultra-dark'] .settings-page .ant-card,
|
.settings-page.is-dark.is-ultra .ant-card,
|
||||||
html[data-theme='ultra-dark'] .nodes-page .ant-card,
|
.nodes-page.is-dark.is-ultra .ant-card,
|
||||||
html[data-theme='ultra-dark'] .api-docs-page .ant-card {
|
.api-docs-page.is-dark.is-ultra .ant-card {
|
||||||
border-color: rgba(255, 255, 255, 0.04);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 2px rgba(0, 0, 0, 0.6),
|
0 1px 2px rgba(0, 0, 0, 0.6),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||||
|
|
@ -45,46 +42,33 @@ html[data-theme='ultra-dark'] .api-docs-page .ant-card {
|
||||||
.nodes-page .ant-card.ant-card-hoverable:hover,
|
.nodes-page .ant-card.ant-card-hoverable:hover,
|
||||||
.api-docs-page .ant-card.ant-card-hoverable:hover {
|
.api-docs-page .ant-card.ant-card-hoverable:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: rgba(0, 0, 0, 0.10);
|
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark .index-page .ant-card.ant-card-hoverable:hover,
|
.index-page.is-dark .ant-card.ant-card-hoverable:hover,
|
||||||
body.dark .clients-page .ant-card.ant-card-hoverable:hover,
|
.clients-page.is-dark .ant-card.ant-card-hoverable:hover,
|
||||||
body.dark .inbounds-page .ant-card.ant-card-hoverable:hover,
|
.inbounds-page.is-dark .ant-card.ant-card-hoverable:hover,
|
||||||
body.dark .xray-page .ant-card.ant-card-hoverable:hover,
|
.xray-page.is-dark .ant-card.ant-card-hoverable:hover,
|
||||||
body.dark .settings-page .ant-card.ant-card-hoverable:hover,
|
.settings-page.is-dark .ant-card.ant-card-hoverable:hover,
|
||||||
body.dark .nodes-page .ant-card.ant-card-hoverable:hover,
|
.nodes-page.is-dark .ant-card.ant-card-hoverable:hover,
|
||||||
body.dark .api-docs-page .ant-card.ant-card-hoverable:hover {
|
.api-docs-page.is-dark .ant-card.ant-card-hoverable:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.12);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 24px rgba(0, 0, 0, 0.5),
|
0 8px 24px rgba(0, 0, 0, 0.5),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card.ant-card-hoverable:hover,
|
.index-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
|
||||||
html[data-theme='ultra-dark'] .clients-page .ant-card.ant-card-hoverable:hover,
|
.clients-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
|
||||||
html[data-theme='ultra-dark'] .inbounds-page .ant-card.ant-card-hoverable:hover,
|
.inbounds-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
|
||||||
html[data-theme='ultra-dark'] .xray-page .ant-card.ant-card-hoverable:hover,
|
.xray-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
|
||||||
html[data-theme='ultra-dark'] .settings-page .ant-card.ant-card-hoverable:hover,
|
.settings-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
|
||||||
html[data-theme='ultra-dark'] .nodes-page .ant-card.ant-card-hoverable:hover,
|
.nodes-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover,
|
||||||
html[data-theme='ultra-dark'] .api-docs-page .ant-card.ant-card-hoverable:hover {
|
.api-docs-page.is-dark.is-ultra .ant-card.ant-card-hoverable:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 24px rgba(0, 0, 0, 0.75),
|
0 8px 24px rgba(0, 0, 0, 0.75),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-head,
|
|
||||||
.clients-page .ant-card .ant-card-head,
|
|
||||||
.inbounds-page .ant-card .ant-card-head,
|
|
||||||
.xray-page .ant-card .ant-card-head,
|
|
||||||
.settings-page .ant-card .ant-card-head,
|
|
||||||
.nodes-page .ant-card .ant-card-head,
|
|
||||||
.api-docs-page .ant-card .ant-card-head {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-actions,
|
.index-page .ant-card .ant-card-actions,
|
||||||
.clients-page .ant-card .ant-card-actions,
|
.clients-page .ant-card .ant-card-actions,
|
||||||
.inbounds-page .ant-card .ant-card-actions,
|
.inbounds-page .ant-card .ant-card-actions,
|
||||||
|
|
@ -92,76 +76,5 @@ html[data-theme='ultra-dark'] .api-docs-page .ant-card.ant-card-hoverable:hover
|
||||||
.settings-page .ant-card .ant-card-actions,
|
.settings-page .ant-card .ant-card-actions,
|
||||||
.nodes-page .ant-card .ant-card-actions,
|
.nodes-page .ant-card .ant-card-actions,
|
||||||
.api-docs-page .ant-card .ant-card-actions {
|
.api-docs-page .ant-card .ant-card-actions {
|
||||||
border-top-color: rgba(0, 0, 0, 0.06);
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.index-page .ant-card .ant-card-actions > li,
|
|
||||||
.clients-page .ant-card .ant-card-actions > li,
|
|
||||||
.inbounds-page .ant-card .ant-card-actions > li,
|
|
||||||
.xray-page .ant-card .ant-card-actions > li,
|
|
||||||
.settings-page .ant-card .ant-card-actions > li,
|
|
||||||
.nodes-page .ant-card .ant-card-actions > li,
|
|
||||||
.api-docs-page .ant-card .ant-card-actions > li {
|
|
||||||
border-inline-end-color: rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card .ant-card-head,
|
|
||||||
body.dark .clients-page .ant-card .ant-card-head,
|
|
||||||
body.dark .inbounds-page .ant-card .ant-card-head,
|
|
||||||
body.dark .xray-page .ant-card .ant-card-head,
|
|
||||||
body.dark .settings-page .ant-card .ant-card-head,
|
|
||||||
body.dark .nodes-page .ant-card .ant-card-head,
|
|
||||||
body.dark .api-docs-page .ant-card .ant-card-head {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card .ant-card-actions,
|
|
||||||
body.dark .clients-page .ant-card .ant-card-actions,
|
|
||||||
body.dark .inbounds-page .ant-card .ant-card-actions,
|
|
||||||
body.dark .xray-page .ant-card .ant-card-actions,
|
|
||||||
body.dark .settings-page .ant-card .ant-card-actions,
|
|
||||||
body.dark .nodes-page .ant-card .ant-card-actions,
|
|
||||||
body.dark .api-docs-page .ant-card .ant-card-actions {
|
|
||||||
border-top-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark .index-page .ant-card .ant-card-actions > li,
|
|
||||||
body.dark .clients-page .ant-card .ant-card-actions > li,
|
|
||||||
body.dark .inbounds-page .ant-card .ant-card-actions > li,
|
|
||||||
body.dark .xray-page .ant-card .ant-card-actions > li,
|
|
||||||
body.dark .settings-page .ant-card .ant-card-actions > li,
|
|
||||||
body.dark .nodes-page .ant-card .ant-card-actions > li,
|
|
||||||
body.dark .api-docs-page .ant-card .ant-card-actions > li {
|
|
||||||
border-inline-end-color: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-head,
|
|
||||||
html[data-theme='ultra-dark'] .clients-page .ant-card .ant-card-head,
|
|
||||||
html[data-theme='ultra-dark'] .inbounds-page .ant-card .ant-card-head,
|
|
||||||
html[data-theme='ultra-dark'] .xray-page .ant-card .ant-card-head,
|
|
||||||
html[data-theme='ultra-dark'] .settings-page .ant-card .ant-card-head,
|
|
||||||
html[data-theme='ultra-dark'] .nodes-page .ant-card .ant-card-head,
|
|
||||||
html[data-theme='ultra-dark'] .api-docs-page .ant-card .ant-card-head {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-actions,
|
|
||||||
html[data-theme='ultra-dark'] .clients-page .ant-card .ant-card-actions,
|
|
||||||
html[data-theme='ultra-dark'] .inbounds-page .ant-card .ant-card-actions,
|
|
||||||
html[data-theme='ultra-dark'] .xray-page .ant-card .ant-card-actions,
|
|
||||||
html[data-theme='ultra-dark'] .settings-page .ant-card .ant-card-actions,
|
|
||||||
html[data-theme='ultra-dark'] .nodes-page .ant-card .ant-card-actions,
|
|
||||||
html[data-theme='ultra-dark'] .api-docs-page .ant-card .ant-card-actions {
|
|
||||||
border-top-color: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme='ultra-dark'] .index-page .ant-card .ant-card-actions > li,
|
|
||||||
html[data-theme='ultra-dark'] .clients-page .ant-card .ant-card-actions > li,
|
|
||||||
html[data-theme='ultra-dark'] .inbounds-page .ant-card .ant-card-actions > li,
|
|
||||||
html[data-theme='ultra-dark'] .xray-page .ant-card .ant-card-actions > li,
|
|
||||||
html[data-theme='ultra-dark'] .settings-page .ant-card .ant-card-actions > li,
|
|
||||||
html[data-theme='ultra-dark'] .nodes-page .ant-card .ant-card-actions > li,
|
|
||||||
html[data-theme='ultra-dark'] .api-docs-page .ant-card .ant-card-actions > li {
|
|
||||||
border-inline-end-color: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
143
frontend/src/styles/page-shell.css
Normal file
143
frontend/src/styles/page-shell.css
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
.index-page,
|
||||||
|
.clients-page,
|
||||||
|
.inbounds-page,
|
||||||
|
.xray-page,
|
||||||
|
.settings-page,
|
||||||
|
.nodes-page,
|
||||||
|
.api-docs-page {
|
||||||
|
--bg-page: #e6e8ec;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page.is-dark,
|
||||||
|
.clients-page.is-dark,
|
||||||
|
.inbounds-page.is-dark,
|
||||||
|
.xray-page.is-dark,
|
||||||
|
.settings-page.is-dark,
|
||||||
|
.nodes-page.is-dark,
|
||||||
|
.api-docs-page.is-dark {
|
||||||
|
--bg-page: #1a1b1f;
|
||||||
|
--bg-card: #23252b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page.is-dark.is-ultra,
|
||||||
|
.clients-page.is-dark.is-ultra,
|
||||||
|
.inbounds-page.is-dark.is-ultra,
|
||||||
|
.xray-page.is-dark.is-ultra,
|
||||||
|
.settings-page.is-dark.is-ultra,
|
||||||
|
.nodes-page.is-dark.is-ultra,
|
||||||
|
.api-docs-page.is-dark.is-ultra {
|
||||||
|
--bg-page: #000;
|
||||||
|
--bg-card: #101013;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page .ant-layout,
|
||||||
|
.index-page .ant-layout-content,
|
||||||
|
.clients-page .ant-layout,
|
||||||
|
.clients-page .ant-layout-content,
|
||||||
|
.inbounds-page .ant-layout,
|
||||||
|
.inbounds-page .ant-layout-content,
|
||||||
|
.xray-page .ant-layout,
|
||||||
|
.xray-page .ant-layout-content,
|
||||||
|
.settings-page .ant-layout,
|
||||||
|
.settings-page .ant-layout-content,
|
||||||
|
.nodes-page .ant-layout,
|
||||||
|
.nodes-page .ant-layout-content,
|
||||||
|
.api-docs-page .ant-layout,
|
||||||
|
.api-docs-page .ant-layout-content {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page .content-shell,
|
||||||
|
.clients-page .content-shell,
|
||||||
|
.inbounds-page .content-shell,
|
||||||
|
.xray-page .content-shell,
|
||||||
|
.settings-page .content-shell,
|
||||||
|
.nodes-page .content-shell,
|
||||||
|
.api-docs-page .content-shell {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page .content-area,
|
||||||
|
.clients-page .content-area,
|
||||||
|
.inbounds-page .content-area,
|
||||||
|
.xray-page .content-area,
|
||||||
|
.settings-page .content-area,
|
||||||
|
.nodes-page .content-area {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.clients-page .content-area,
|
||||||
|
.inbounds-page .content-area,
|
||||||
|
.nodes-page .content-area {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spacer {
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page .header-row,
|
||||||
|
.xray-page .header-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page .header-actions,
|
||||||
|
.xray-page .header-actions {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page .header-info,
|
||||||
|
.xray-page .header-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only .ant-tabs-nav {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only .ant-tabs-nav-wrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only .ant-tabs-nav-list {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only .ant-tabs-tab {
|
||||||
|
flex: 1 1 0;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only .ant-tabs-tab .anticon {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-only .ant-tabs-nav-operations {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-page .summary-card,
|
||||||
|
.inbounds-page .summary-card,
|
||||||
|
.nodes-page .summary-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.clients-page .summary-card,
|
||||||
|
.inbounds-page .summary-card,
|
||||||
|
.nodes-page .summary-card {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/src/styles/utils.css
Normal file
29
frontend/src/styles/utils.css
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
.mt-4 { margin-top: 4px; }
|
||||||
|
.mt-8 { margin-top: 8px; }
|
||||||
|
.mt-10 { margin-top: 10px; }
|
||||||
|
.mt-12 { margin-top: 12px; }
|
||||||
|
.mt-20 { margin-top: 20px; }
|
||||||
|
|
||||||
|
.mb-4 { margin-bottom: 4px; }
|
||||||
|
.mb-8 { margin-bottom: 8px; }
|
||||||
|
.mb-10 { margin-bottom: 10px; }
|
||||||
|
.mb-12 { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.ml-8 { margin-left: 8px; }
|
||||||
|
|
||||||
|
.my-8 { margin: 8px 0; }
|
||||||
|
.my-10 { margin: 10px 0; }
|
||||||
|
|
||||||
|
.zero-margin { margin: 0; }
|
||||||
|
|
||||||
|
.random-icon {
|
||||||
|
margin-left: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ant-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-icon {
|
||||||
|
margin-left: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ant-color-error);
|
||||||
|
}
|
||||||
|
|
@ -1,965 +0,0 @@
|
||||||
import axios from 'axios';
|
|
||||||
import { getMessage } from './messageBus';
|
|
||||||
|
|
||||||
export class Msg {
|
|
||||||
constructor(success = false, msg = "", obj = null) {
|
|
||||||
this.success = success;
|
|
||||||
this.msg = msg;
|
|
||||||
this.obj = obj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HttpUtil {
|
|
||||||
static _handleMsg(msg) {
|
|
||||||
if (!(msg instanceof Msg) || msg.msg === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const messageType = msg.success ? 'success' : 'error';
|
|
||||||
getMessage()[messageType](msg.msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
static _respToMsg(resp) {
|
|
||||||
if (!resp || !resp.data) {
|
|
||||||
return new Msg(false, 'No response data');
|
|
||||||
}
|
|
||||||
const { data } = resp;
|
|
||||||
if (data == null) {
|
|
||||||
return new Msg(true);
|
|
||||||
}
|
|
||||||
if (typeof data === 'object' && 'success' in data) {
|
|
||||||
return new Msg(data.success, data.msg, data.obj);
|
|
||||||
}
|
|
||||||
return typeof data === 'object' ? data : new Msg(false, 'unknown data:', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async get(url, params, options = {}) {
|
|
||||||
const { silent, ...axiosOpts } = options;
|
|
||||||
try {
|
|
||||||
const resp = await axios.get(url, { params, ...axiosOpts });
|
|
||||||
const msg = this._respToMsg(resp);
|
|
||||||
if (!silent) this._handleMsg(msg);
|
|
||||||
return msg;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GET request failed:', error);
|
|
||||||
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
|
||||||
if (!silent) this._handleMsg(errorMsg);
|
|
||||||
return errorMsg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async post(url, data, options = {}) {
|
|
||||||
const { silent, ...axiosOpts } = options;
|
|
||||||
try {
|
|
||||||
const resp = await axios.post(url, data, axiosOpts);
|
|
||||||
const msg = this._respToMsg(resp);
|
|
||||||
if (!silent) this._handleMsg(msg);
|
|
||||||
return msg;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('POST request failed:', error);
|
|
||||||
const errorMsg = new Msg(false, error.response?.data?.message || error.message || 'Request failed');
|
|
||||||
if (!silent) this._handleMsg(errorMsg);
|
|
||||||
return errorMsg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async postWithModal(url, data, modal) {
|
|
||||||
if (modal) {
|
|
||||||
modal.loading(true);
|
|
||||||
}
|
|
||||||
const msg = await this.post(url, data);
|
|
||||||
if (modal) {
|
|
||||||
modal.loading(false);
|
|
||||||
if (msg instanceof Msg && msg.success) {
|
|
||||||
modal.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyDocumentTitle() {
|
|
||||||
const host = window.location.hostname;
|
|
||||||
if (!host) return;
|
|
||||||
const current = document.title.trim();
|
|
||||||
document.title = current ? `${host} - ${current}` : host;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PromiseUtil {
|
|
||||||
static async sleep(timeout) {
|
|
||||||
await new Promise(resolve => {
|
|
||||||
setTimeout(resolve, timeout)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RandomUtil {
|
|
||||||
static getSeq({ type = "default", hasNumbers = true, hasLowercase = true, hasUppercase = true } = {}) {
|
|
||||||
let seq = '';
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "hex":
|
|
||||||
seq += "0123456789abcdef";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (hasNumbers) seq += "0123456789";
|
|
||||||
if (hasLowercase) seq += "abcdefghijklmnopqrstuvwxyz";
|
|
||||||
if (hasUppercase) seq += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return seq;
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomInteger(min, max) {
|
|
||||||
const range = max - min + 1;
|
|
||||||
const randomBuffer = new Uint32Array(1);
|
|
||||||
window.crypto.getRandomValues(randomBuffer);
|
|
||||||
return Math.floor((randomBuffer[0] / (0xFFFFFFFF + 1)) * range) + min;
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomSeq(count, options = {}) {
|
|
||||||
const seq = this.getSeq(options);
|
|
||||||
const seqLength = seq.length;
|
|
||||||
const randomValues = new Uint32Array(count);
|
|
||||||
window.crypto.getRandomValues(randomValues);
|
|
||||||
return Array.from(randomValues, v => seq[v % seqLength]).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomShortIds() {
|
|
||||||
const lengths = [2, 4, 6, 8, 10, 12, 14, 16].sort(() => Math.random() - 0.5);
|
|
||||||
|
|
||||||
return lengths.map(len => this.randomSeq(len, { type: "hex" })).join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomLowerAndNum(len) {
|
|
||||||
return this.randomSeq(len, { hasUppercase: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomUUID() {
|
|
||||||
if (window.location.protocol === "https:") {
|
|
||||||
return window.crypto.randomUUID();
|
|
||||||
} else {
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
|
|
||||||
.replace(/[xy]/g, function (c) {
|
|
||||||
const randomValues = new Uint8Array(1);
|
|
||||||
window.crypto.getRandomValues(randomValues);
|
|
||||||
let randomValue = randomValues[0] % 16;
|
|
||||||
let calculatedValue = (c === 'x') ? randomValue : (randomValue & 0x3 | 0x8);
|
|
||||||
return calculatedValue.toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomShadowsocksPassword(method = '2022-blake3-aes-256-gcm') {
|
|
||||||
let length = 32;
|
|
||||||
|
|
||||||
if (method === '2022-blake3-aes-128-gcm') {
|
|
||||||
length = 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
const array = new Uint8Array(length);
|
|
||||||
|
|
||||||
window.crypto.getRandomValues(array);
|
|
||||||
|
|
||||||
return Base64.alternativeEncode(String.fromCharCode(...array));
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomBase64(length = 16) {
|
|
||||||
const array = new Uint8Array(length);
|
|
||||||
window.crypto.getRandomValues(array);
|
|
||||||
return Base64.alternativeEncode(String.fromCharCode(...array));
|
|
||||||
}
|
|
||||||
|
|
||||||
static randomBase32String(length = 16) {
|
|
||||||
const array = new Uint8Array(length);
|
|
||||||
|
|
||||||
window.crypto.getRandomValues(array);
|
|
||||||
|
|
||||||
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
||||||
let result = '';
|
|
||||||
let bits = 0;
|
|
||||||
let buffer = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < array.length; i++) {
|
|
||||||
buffer = (buffer << 8) | array[i];
|
|
||||||
bits += 8;
|
|
||||||
|
|
||||||
while (bits >= 5) {
|
|
||||||
bits -= 5;
|
|
||||||
result += base32Chars[(buffer >>> bits) & 0x1F];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bits > 0) {
|
|
||||||
result += base32Chars[(buffer << (5 - bits)) & 0x1F];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ObjectUtil {
|
|
||||||
static getPropIgnoreCase(obj, prop) {
|
|
||||||
for (const name in obj) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(obj, name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (name.toLowerCase() === prop.toLowerCase()) {
|
|
||||||
return obj[name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
static deepSearch(obj, key) {
|
|
||||||
if (obj instanceof Array) {
|
|
||||||
for (let i = 0; i < obj.length; ++i) {
|
|
||||||
if (this.deepSearch(obj[i], key)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (obj instanceof Object) {
|
|
||||||
for (let name in obj) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(obj, name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (this.deepSearch(obj[name], key)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return this.isEmpty(obj) ? false : obj.toString().toLowerCase().indexOf(key.toLowerCase()) >= 0;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static isEmpty(obj) {
|
|
||||||
return obj === null || obj === undefined || obj === '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static isArrEmpty(arr) {
|
|
||||||
return !Array.isArray(arr) || arr.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static copyArr(dest, src) {
|
|
||||||
dest.splice(0);
|
|
||||||
for (const item of src) {
|
|
||||||
dest.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static clone(obj) {
|
|
||||||
let newObj;
|
|
||||||
if (obj instanceof Array) {
|
|
||||||
newObj = [];
|
|
||||||
this.copyArr(newObj, obj);
|
|
||||||
} else if (obj instanceof Object) {
|
|
||||||
newObj = {};
|
|
||||||
for (const key of Object.keys(obj)) {
|
|
||||||
newObj[key] = obj[key];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newObj = obj;
|
|
||||||
}
|
|
||||||
return newObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
static deepClone(obj) {
|
|
||||||
let newObj;
|
|
||||||
if (obj instanceof Array) {
|
|
||||||
newObj = [];
|
|
||||||
for (const item of obj) {
|
|
||||||
newObj.push(this.deepClone(item));
|
|
||||||
}
|
|
||||||
} else if (obj instanceof Object) {
|
|
||||||
newObj = {};
|
|
||||||
for (const key of Object.keys(obj)) {
|
|
||||||
newObj[key] = this.deepClone(obj[key]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newObj = obj;
|
|
||||||
}
|
|
||||||
return newObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
static cloneProps(dest, src, ...ignoreProps) {
|
|
||||||
if (dest == null || src == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ignoreEmpty = this.isArrEmpty(ignoreProps);
|
|
||||||
for (const key of Object.keys(src)) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(src, key)) {
|
|
||||||
continue;
|
|
||||||
} else if (!Object.prototype.hasOwnProperty.call(dest, key)) {
|
|
||||||
continue;
|
|
||||||
} else if (src[key] === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ignoreEmpty) {
|
|
||||||
dest[key] = src[key];
|
|
||||||
} else {
|
|
||||||
let ignore = false;
|
|
||||||
for (let i = 0; i < ignoreProps.length; ++i) {
|
|
||||||
if (key === ignoreProps[i]) {
|
|
||||||
ignore = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ignore) {
|
|
||||||
dest[key] = src[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static delProps(obj, ...props) {
|
|
||||||
for (const prop of props) {
|
|
||||||
if (prop in obj) {
|
|
||||||
delete obj[prop];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static execute(func, ...args) {
|
|
||||||
if (!this.isEmpty(func) && typeof func === 'function') {
|
|
||||||
func(...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static orDefault(obj, defaultValue) {
|
|
||||||
if (obj == null) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
static equals(a, b) {
|
|
||||||
// shallow, symmetric comparison so newly added fields also affect equality
|
|
||||||
const aKeys = Object.keys(a);
|
|
||||||
const bKeys = Object.keys(b);
|
|
||||||
if (aKeys.length !== bKeys.length) return false;
|
|
||||||
for (const key of aKeys) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
|
||||||
if (a[key] !== b[key]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Wireguard {
|
|
||||||
static gf(init) {
|
|
||||||
var r = new Float64Array(16);
|
|
||||||
if (init) {
|
|
||||||
for (var i = 0; i < init.length; ++i)
|
|
||||||
r[i] = init[i];
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
static pack(o, n) {
|
|
||||||
let b;
|
|
||||||
const m = this.gf(), t = this.gf();
|
|
||||||
for (let i = 0; i < 16; ++i)
|
|
||||||
t[i] = n[i];
|
|
||||||
this.carry(t);
|
|
||||||
this.carry(t);
|
|
||||||
this.carry(t);
|
|
||||||
for (let j = 0; j < 2; ++j) {
|
|
||||||
m[0] = t[0] - 0xffed;
|
|
||||||
for (let i = 1; i < 15; ++i) {
|
|
||||||
m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
|
|
||||||
m[i - 1] &= 0xffff;
|
|
||||||
}
|
|
||||||
m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1);
|
|
||||||
b = (m[15] >> 16) & 1;
|
|
||||||
m[14] &= 0xffff;
|
|
||||||
this.cswap(t, m, 1 - b);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 16; ++i) {
|
|
||||||
o[2 * i] = t[i] & 0xff;
|
|
||||||
o[2 * i + 1] = t[i] >> 8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static carry(o) {
|
|
||||||
for (let i = 0; i < 16; ++i) {
|
|
||||||
o[(i + 1) % 16] += (i < 15 ? 1 : 38) * Math.floor(o[i] / 65536);
|
|
||||||
o[i] &= 0xffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static cswap(p, q, b) {
|
|
||||||
const c = ~(b - 1);
|
|
||||||
let t;
|
|
||||||
for (let i = 0; i < 16; ++i) {
|
|
||||||
t = c & (p[i] ^ q[i]);
|
|
||||||
p[i] ^= t;
|
|
||||||
q[i] ^= t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static add(o, a, b) {
|
|
||||||
for (let i = 0; i < 16; ++i)
|
|
||||||
o[i] = (a[i] + b[i]) | 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static subtract(o, a, b) {
|
|
||||||
for (let i = 0; i < 16; ++i)
|
|
||||||
o[i] = (a[i] - b[i]) | 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static multmod(o, a, b) {
|
|
||||||
const t = new Float64Array(31);
|
|
||||||
for (let i = 0; i < 16; ++i) {
|
|
||||||
for (let j = 0; j < 16; ++j)
|
|
||||||
t[i + j] += a[i] * b[j];
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 15; ++i)
|
|
||||||
t[i] += 38 * t[i + 16];
|
|
||||||
for (let i = 0; i < 16; ++i)
|
|
||||||
o[i] = t[i];
|
|
||||||
this.carry(o);
|
|
||||||
this.carry(o);
|
|
||||||
}
|
|
||||||
|
|
||||||
static invert(o, i) {
|
|
||||||
const c = this.gf();
|
|
||||||
for (let a = 0; a < 16; ++a)
|
|
||||||
c[a] = i[a];
|
|
||||||
for (let a = 253; a >= 0; --a) {
|
|
||||||
this.multmod(c, c, c);
|
|
||||||
if (a !== 2 && a !== 4)
|
|
||||||
this.multmod(c, c, i);
|
|
||||||
}
|
|
||||||
for (let a = 0; a < 16; ++a)
|
|
||||||
o[a] = c[a];
|
|
||||||
}
|
|
||||||
|
|
||||||
static clamp(z) {
|
|
||||||
z[31] = (z[31] & 127) | 64;
|
|
||||||
z[0] &= 248;
|
|
||||||
}
|
|
||||||
|
|
||||||
static generatePublicKey(privateKey) {
|
|
||||||
let r;
|
|
||||||
const z = new Uint8Array(32);
|
|
||||||
const a = this.gf([1]),
|
|
||||||
b = this.gf([9]),
|
|
||||||
c = this.gf(),
|
|
||||||
d = this.gf([1]),
|
|
||||||
e = this.gf(),
|
|
||||||
f = this.gf(),
|
|
||||||
_121665 = this.gf([0xdb41, 1]),
|
|
||||||
_9 = this.gf([9]);
|
|
||||||
for (let i = 0; i < 32; ++i)
|
|
||||||
z[i] = privateKey[i];
|
|
||||||
this.clamp(z);
|
|
||||||
for (let i = 254; i >= 0; --i) {
|
|
||||||
r = (z[i >>> 3] >>> (i & 7)) & 1;
|
|
||||||
this.cswap(a, b, r);
|
|
||||||
this.cswap(c, d, r);
|
|
||||||
this.add(e, a, c);
|
|
||||||
this.subtract(a, a, c);
|
|
||||||
this.add(c, b, d);
|
|
||||||
this.subtract(b, b, d);
|
|
||||||
this.multmod(d, e, e);
|
|
||||||
this.multmod(f, a, a);
|
|
||||||
this.multmod(a, c, a);
|
|
||||||
this.multmod(c, b, e);
|
|
||||||
this.add(e, a, c);
|
|
||||||
this.subtract(a, a, c);
|
|
||||||
this.multmod(b, a, a);
|
|
||||||
this.subtract(c, d, f);
|
|
||||||
this.multmod(a, c, _121665);
|
|
||||||
this.add(a, a, d);
|
|
||||||
this.multmod(c, c, a);
|
|
||||||
this.multmod(a, d, f);
|
|
||||||
this.multmod(d, b, _9);
|
|
||||||
this.multmod(b, e, e);
|
|
||||||
this.cswap(a, b, r);
|
|
||||||
this.cswap(c, d, r);
|
|
||||||
}
|
|
||||||
this.invert(c, c);
|
|
||||||
this.multmod(a, a, c);
|
|
||||||
this.pack(z, a);
|
|
||||||
return z;
|
|
||||||
}
|
|
||||||
|
|
||||||
static generatePresharedKey() {
|
|
||||||
var privateKey = new Uint8Array(32);
|
|
||||||
window.crypto.getRandomValues(privateKey);
|
|
||||||
return privateKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
static generatePrivateKey() {
|
|
||||||
var privateKey = this.generatePresharedKey();
|
|
||||||
this.clamp(privateKey);
|
|
||||||
return privateKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
static encodeBase64(dest, src) {
|
|
||||||
var input = Uint8Array.from([(src[0] >> 2) & 63, ((src[0] << 4) | (src[1] >> 4)) & 63, ((src[1] << 2) | (src[2] >> 6)) & 63, src[2] & 63]);
|
|
||||||
for (var i = 0; i < 4; ++i)
|
|
||||||
dest[i] = input[i] + 65 +
|
|
||||||
(((25 - input[i]) >> 8) & 6) -
|
|
||||||
(((51 - input[i]) >> 8) & 75) -
|
|
||||||
(((61 - input[i]) >> 8) & 15) +
|
|
||||||
(((62 - input[i]) >> 8) & 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
static keyToBase64(key) {
|
|
||||||
var i, base64 = new Uint8Array(44);
|
|
||||||
for (i = 0; i < 32 / 3; ++i)
|
|
||||||
this.encodeBase64(base64.subarray(i * 4), key.subarray(i * 3));
|
|
||||||
this.encodeBase64(base64.subarray(i * 4), Uint8Array.from([key[i * 3 + 0], key[i * 3 + 1], 0]));
|
|
||||||
base64[43] = 61;
|
|
||||||
return String.fromCharCode.apply(null, base64);
|
|
||||||
}
|
|
||||||
|
|
||||||
static keyFromBase64(encoded) {
|
|
||||||
const binaryStr = atob(encoded);
|
|
||||||
const bytes = new Uint8Array(binaryStr.length);
|
|
||||||
for (let i = 0; i < binaryStr.length; i++) {
|
|
||||||
bytes[i] = binaryStr.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
static generateKeypair(secretKey = '') {
|
|
||||||
var privateKey = secretKey.length > 0 ? this.keyFromBase64(secretKey) : this.generatePrivateKey();
|
|
||||||
var publicKey = this.generatePublicKey(privateKey);
|
|
||||||
return {
|
|
||||||
publicKey: this.keyToBase64(publicKey),
|
|
||||||
privateKey: secretKey.length > 0 ? secretKey : this.keyToBase64(privateKey)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ClipboardManager {
|
|
||||||
static async copyText(content = "") {
|
|
||||||
const text = String(content ?? "");
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
/* fall through to legacy path */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ClipboardManager._legacyCopy(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
static _legacyCopy(text) {
|
|
||||||
const textarea = document.createElement('textarea');
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.setAttribute('readonly', '');
|
|
||||||
textarea.setAttribute('aria-hidden', 'true');
|
|
||||||
textarea.style.position = 'absolute';
|
|
||||||
textarea.style.left = '-9999px';
|
|
||||||
textarea.style.top = '0';
|
|
||||||
textarea.style.opacity = '1';
|
|
||||||
|
|
||||||
const active = document.activeElement;
|
|
||||||
const host = (active && active !== document.body && active.parentElement)
|
|
||||||
? active.parentElement
|
|
||||||
: document.body;
|
|
||||||
host.appendChild(textarea);
|
|
||||||
|
|
||||||
const prevSelection = document.getSelection()?.rangeCount
|
|
||||||
? document.getSelection().getRangeAt(0)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let ok = false;
|
|
||||||
try {
|
|
||||||
textarea.focus({ preventScroll: true });
|
|
||||||
textarea.select();
|
|
||||||
textarea.setSelectionRange(0, text.length);
|
|
||||||
ok = document.execCommand('copy');
|
|
||||||
} catch {
|
|
||||||
/* keep ok as false */
|
|
||||||
}
|
|
||||||
|
|
||||||
host.removeChild(textarea);
|
|
||||||
if (active && typeof active.focus === 'function') {
|
|
||||||
try { active.focus({ preventScroll: true }); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (prevSelection) {
|
|
||||||
const sel = document.getSelection();
|
|
||||||
sel?.removeAllRanges();
|
|
||||||
sel?.addRange(prevSelection);
|
|
||||||
}
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Base64 {
|
|
||||||
static encode(content = "", safe = false) {
|
|
||||||
if (safe) {
|
|
||||||
return Base64.encode(content)
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/=/g, '')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.btoa(
|
|
||||||
String.fromCharCode(...new TextEncoder().encode(content))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static alternativeEncode(content) {
|
|
||||||
return window.btoa(
|
|
||||||
content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static decode(content = "") {
|
|
||||||
return new TextDecoder()
|
|
||||||
.decode(
|
|
||||||
Uint8Array.from(window.atob(content), c => c.charCodeAt(0))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SizeFormatter {
|
|
||||||
static ONE_KB = 1024;
|
|
||||||
static ONE_MB = this.ONE_KB * 1024;
|
|
||||||
static ONE_GB = this.ONE_MB * 1024;
|
|
||||||
static ONE_TB = this.ONE_GB * 1024;
|
|
||||||
static ONE_PB = this.ONE_TB * 1024;
|
|
||||||
|
|
||||||
static sizeFormat(size) {
|
|
||||||
if (size <= 0) return "0 B";
|
|
||||||
if (size < this.ONE_KB) return size.toFixed(0) + " B";
|
|
||||||
if (size < this.ONE_MB) return (size / this.ONE_KB).toFixed(2) + " KB";
|
|
||||||
if (size < this.ONE_GB) return (size / this.ONE_MB).toFixed(2) + " MB";
|
|
||||||
if (size < this.ONE_TB) return (size / this.ONE_GB).toFixed(2) + " GB";
|
|
||||||
if (size < this.ONE_PB) return (size / this.ONE_TB).toFixed(2) + " TB";
|
|
||||||
return (size / this.ONE_PB).toFixed(2) + " PB";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CPUFormatter {
|
|
||||||
static cpuSpeedFormat(speed) {
|
|
||||||
return speed > 1000 ? (speed / 1000).toFixed(2) + " GHz" : speed.toFixed(2) + " MHz";
|
|
||||||
}
|
|
||||||
|
|
||||||
static cpuCoreFormat(cores) {
|
|
||||||
return cores === 1 ? "1 Core" : cores + " Cores";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TimeFormatter {
|
|
||||||
static formatSecond(second) {
|
|
||||||
if (second < 60) return second.toFixed(0) + 's';
|
|
||||||
if (second < 3600) return (second / 60).toFixed(0) + 'm';
|
|
||||||
if (second < 3600 * 24) return (second / 3600).toFixed(0) + 'h';
|
|
||||||
let day = Math.floor(second / 3600 / 24);
|
|
||||||
let remain = ((second / 3600) - (day * 24)).toFixed(0);
|
|
||||||
return day + 'd' + (remain > 0 ? ' ' + remain + 'h' : '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NumberFormatter {
|
|
||||||
static addZero(num) {
|
|
||||||
return num < 10 ? "0" + num : num;
|
|
||||||
}
|
|
||||||
|
|
||||||
static toFixed(num, n) {
|
|
||||||
n = Math.pow(10, n);
|
|
||||||
return Math.floor(num * n) / n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Utils {
|
|
||||||
static debounce(fn, delay) {
|
|
||||||
let timeoutID = null;
|
|
||||||
return function () {
|
|
||||||
clearTimeout(timeoutID);
|
|
||||||
let args = arguments;
|
|
||||||
let that = this;
|
|
||||||
timeoutID = setTimeout(() => fn.apply(that, args), delay);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CookieManager {
|
|
||||||
static getCookie(cname) {
|
|
||||||
let name = cname + '=';
|
|
||||||
let ca = document.cookie.split(';');
|
|
||||||
for (let c of ca) {
|
|
||||||
c = c.trim();
|
|
||||||
if (c.indexOf(name) === 0) {
|
|
||||||
return decodeURIComponent(c.substring(name.length, c.length));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static setCookie(cname, cvalue, exdays) {
|
|
||||||
let expires = '';
|
|
||||||
if (exdays) {
|
|
||||||
const d = new Date();
|
|
||||||
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
|
|
||||||
expires = 'expires=' + d.toUTCString() + ';';
|
|
||||||
}
|
|
||||||
document.cookie = cname + '=' + encodeURIComponent(cvalue) + ';' + expires + 'path=/';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AD-Vue 4 semantic palette — kept in one place so the client/inbound
|
|
||||||
// rows match the rest of the panel. Purple is reserved for the
|
|
||||||
// "no quota / no expiry / unlimited" sentinel since the AD-Vue green
|
|
||||||
// would otherwise read as "healthy / under limit".
|
|
||||||
const COLORS = {
|
|
||||||
success: '#389e0a', // AD-Vue green-7 — within quota (toned down from green-6 #52c41a, which was too bright on dark themes)
|
|
||||||
warning: '#faad14', // AD-Vue gold — close to quota / about to expire
|
|
||||||
danger: '#ff4d4f', // AD-Vue red — depleted / expired
|
|
||||||
purple: '#722ed1', // AD-Vue purple — unlimited / no expiry
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ColorUtils {
|
|
||||||
static usageColor(data, threshold, total) {
|
|
||||||
switch (true) {
|
|
||||||
case data === null: return "purple";
|
|
||||||
case total < 0: return "green";
|
|
||||||
case total == 0: return "purple";
|
|
||||||
case data < total - threshold: return "green";
|
|
||||||
case data < total: return "orange";
|
|
||||||
default: return "red";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static clientUsageColor(clientStats, trafficDiff) {
|
|
||||||
switch (true) {
|
|
||||||
case !clientStats || clientStats.total == 0: return COLORS.purple;
|
|
||||||
case clientStats.up + clientStats.down < clientStats.total - trafficDiff: return COLORS.success;
|
|
||||||
case clientStats.up + clientStats.down < clientStats.total: return COLORS.warning;
|
|
||||||
default: return COLORS.danger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static userExpiryColor(threshold, client, isDark = false) {
|
|
||||||
if (!client.enable) return isDark ? '#2c3950' : '#bcbcbc';
|
|
||||||
let now = new Date().getTime(), expiry = client.expiryTime;
|
|
||||||
switch (true) {
|
|
||||||
case expiry === null: return COLORS.purple;
|
|
||||||
case expiry < 0: return COLORS.success;
|
|
||||||
case expiry == 0: return COLORS.purple;
|
|
||||||
case now < expiry - threshold: return COLORS.success;
|
|
||||||
case now < expiry: return COLORS.warning;
|
|
||||||
default: return COLORS.danger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ArrayUtils {
|
|
||||||
static doAllItemsExist(array1, array2) {
|
|
||||||
return array1.every(item => array2.includes(item));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class URLBuilder {
|
|
||||||
static buildURL({ host, port, isTLS, base, path }) {
|
|
||||||
if (!host || host.length === 0) host = window.location.hostname;
|
|
||||||
if (!port || port.length === 0) port = window.location.port;
|
|
||||||
if (isTLS === undefined) isTLS = window.location.protocol === "https:";
|
|
||||||
|
|
||||||
const protocol = isTLS ? "https:" : "http:";
|
|
||||||
port = String(port);
|
|
||||||
if (port === "" || (isTLS && port === "443") || (!isTLS && port === "80")) {
|
|
||||||
port = "";
|
|
||||||
} else {
|
|
||||||
port = `:${port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${protocol}//${host}${port}${base}${path}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LanguageManager {
|
|
||||||
static supportedLanguages = [
|
|
||||||
{
|
|
||||||
name: "العربية",
|
|
||||||
value: "ar-EG",
|
|
||||||
icon: "🇪🇬",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "English",
|
|
||||||
value: "en-US",
|
|
||||||
icon: "🇺🇸",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "فارسی",
|
|
||||||
value: "fa-IR",
|
|
||||||
icon: "🇮🇷",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "简体中文",
|
|
||||||
value: "zh-CN",
|
|
||||||
icon: "🇨🇳",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "繁體中文",
|
|
||||||
value: "zh-TW",
|
|
||||||
icon: "🇹🇼",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "日本語",
|
|
||||||
value: "ja-JP",
|
|
||||||
icon: "🇯🇵",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Русский",
|
|
||||||
value: "ru-RU",
|
|
||||||
icon: "🇷🇺",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Tiếng Việt",
|
|
||||||
value: "vi-VN",
|
|
||||||
icon: "🇻🇳",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Español",
|
|
||||||
value: "es-ES",
|
|
||||||
icon: "🇪🇸",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Indonesian",
|
|
||||||
value: "id-ID",
|
|
||||||
icon: "🇮🇩",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Український",
|
|
||||||
value: "uk-UA",
|
|
||||||
icon: "🇺🇦",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Türkçe",
|
|
||||||
value: "tr-TR",
|
|
||||||
icon: "🇹🇷",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Português",
|
|
||||||
value: "pt-BR",
|
|
||||||
icon: "🇧🇷",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
static getLanguage() {
|
|
||||||
let lang = CookieManager.getCookie("lang");
|
|
||||||
|
|
||||||
if (!lang) {
|
|
||||||
if (window.navigator) {
|
|
||||||
lang = window.navigator.language || window.navigator.userLanguage;
|
|
||||||
|
|
||||||
const simularLangs = [
|
|
||||||
["ar", this.supportedLanguages[0].value],
|
|
||||||
["fa", this.supportedLanguages[2].value],
|
|
||||||
["ja", this.supportedLanguages[5].value],
|
|
||||||
["ru", this.supportedLanguages[6].value],
|
|
||||||
["vi", this.supportedLanguages[7].value],
|
|
||||||
["es", this.supportedLanguages[8].value],
|
|
||||||
["id", this.supportedLanguages[9].value],
|
|
||||||
["uk", this.supportedLanguages[10].value],
|
|
||||||
["tr", this.supportedLanguages[11].value],
|
|
||||||
["pt", this.supportedLanguages[12].value],
|
|
||||||
]
|
|
||||||
|
|
||||||
simularLangs.forEach((pair) => {
|
|
||||||
if (lang === pair[0]) {
|
|
||||||
lang = pair[1];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (LanguageManager.isSupportLanguage(lang)) {
|
|
||||||
CookieManager.setCookie("lang", lang);
|
|
||||||
} else {
|
|
||||||
CookieManager.setCookie("lang", "en-US");
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
CookieManager.setCookie("lang", "en-US");
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lang;
|
|
||||||
}
|
|
||||||
|
|
||||||
static setLanguage(language) {
|
|
||||||
if (!LanguageManager.isSupportLanguage(language)) {
|
|
||||||
language = "en-US";
|
|
||||||
}
|
|
||||||
|
|
||||||
CookieManager.setCookie("lang", language);
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
static isSupportLanguage(language) {
|
|
||||||
const languageFilter = LanguageManager.supportedLanguages.filter((lang) => {
|
|
||||||
return lang.value === language
|
|
||||||
})
|
|
||||||
|
|
||||||
return languageFilter.length > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileManager {
|
|
||||||
static downloadTextFile(content, filename = 'file.txt', options = { type: "text/plain" }) {
|
|
||||||
let link = window.document.createElement('a');
|
|
||||||
|
|
||||||
link.download = filename;
|
|
||||||
link.style.border = '0';
|
|
||||||
link.style.padding = '0';
|
|
||||||
link.style.margin = '0';
|
|
||||||
link.style.position = 'absolute';
|
|
||||||
link.style.left = '-9999px';
|
|
||||||
link.style.top = `${window.pageYOffset || window.document.documentElement.scrollTop}px`;
|
|
||||||
link.href = URL.createObjectURL(new Blob([content], options));
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
URL.revokeObjectURL(link.href);
|
|
||||||
|
|
||||||
link.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IntlUtil {
|
|
||||||
// For Jalali display, always use fa-IR locale (its default calendar
|
|
||||||
// is Persian) so we get a clean "1405/07/03 12:00:00" format with
|
|
||||||
// Persian digits, without the awkward "AP" era suffix that appears
|
|
||||||
// when other locales force `-u-ca-persian`.
|
|
||||||
static formatDate(date, calendar = "gregorian") {
|
|
||||||
const language = LanguageManager.getLanguage()
|
|
||||||
const locale = calendar === "jalalian" ? "fa-IR" : language
|
|
||||||
|
|
||||||
const intlOptions = {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const intl = new Intl.DateTimeFormat(
|
|
||||||
locale,
|
|
||||||
intlOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
return intl.format(new Date(date))
|
|
||||||
}
|
|
||||||
static formatRelativeTime(date) {
|
|
||||||
const language = LanguageManager.getLanguage()
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
// Handle delayed start (negative expiryTime values)
|
|
||||||
const diff = date < 0
|
|
||||||
? Math.round(date / (1000 * 60 * 60 * 24))
|
|
||||||
: Math.round((date - now) / (1000 * 60 * 60 * 24))
|
|
||||||
const formatter = new Intl.RelativeTimeFormat(language, { numeric: 'auto' })
|
|
||||||
|
|
||||||
return formatter.format(diff, 'day');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
932
frontend/src/utils/index.ts
Normal file
932
frontend/src/utils/index.ts
Normal file
|
|
@ -0,0 +1,932 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import { getMessage } from './messageBus';
|
||||||
|
|
||||||
|
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export class Msg<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
msg: string;
|
||||||
|
obj: T | null;
|
||||||
|
|
||||||
|
constructor(success: boolean = false, msg: string = '', obj: T | null = null) {
|
||||||
|
this.success = success;
|
||||||
|
this.msg = msg;
|
||||||
|
this.obj = obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpOptions extends AxiosRequestConfig {
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpModal {
|
||||||
|
loading: (state: boolean) => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpUtil {
|
||||||
|
static _handleMsg(msg: unknown): void {
|
||||||
|
if (!(msg instanceof Msg) || msg.msg === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const messageType = msg.success ? 'success' : 'error';
|
||||||
|
getMessage()[messageType](msg.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
static _respToMsg(resp: AxiosResponse | undefined): Msg {
|
||||||
|
if (!resp || !resp.data) {
|
||||||
|
return new Msg(false, 'No response data');
|
||||||
|
}
|
||||||
|
const { data } = resp;
|
||||||
|
if (data == null) {
|
||||||
|
return new Msg(true);
|
||||||
|
}
|
||||||
|
if (typeof data === 'object' && 'success' in (data as object)) {
|
||||||
|
const d = data as RespEnvelope;
|
||||||
|
return new Msg(Boolean(d.success), typeof d.msg === 'string' ? d.msg : '', d.obj ?? null);
|
||||||
|
}
|
||||||
|
return typeof data === 'object' ? (data as Msg) : new Msg(false, 'unknown data:', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
static async get<T = any>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
|
||||||
|
const { silent, ...axiosOpts } = options;
|
||||||
|
try {
|
||||||
|
const resp = await axios.get(url, { params, ...axiosOpts });
|
||||||
|
const msg = this._respToMsg(resp) as Msg<T>;
|
||||||
|
if (!silent) this._handleMsg(msg);
|
||||||
|
return msg;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET request failed:', error);
|
||||||
|
const err = error as AxiosError<{ message?: string }>;
|
||||||
|
const errorMsg = new Msg<T>(false, err.response?.data?.message || err.message || 'Request failed');
|
||||||
|
if (!silent) this._handleMsg(errorMsg);
|
||||||
|
return errorMsg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
static async post<T = any>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
|
||||||
|
const { silent, ...axiosOpts } = options;
|
||||||
|
try {
|
||||||
|
const resp = await axios.post(url, data, axiosOpts);
|
||||||
|
const msg = this._respToMsg(resp) as Msg<T>;
|
||||||
|
if (!silent) this._handleMsg(msg);
|
||||||
|
return msg;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('POST request failed:', error);
|
||||||
|
const err = error as AxiosError<{ message?: string }>;
|
||||||
|
const errorMsg = new Msg<T>(false, err.response?.data?.message || err.message || 'Request failed');
|
||||||
|
if (!silent) this._handleMsg(errorMsg);
|
||||||
|
return errorMsg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
static async postWithModal<T = any>(url: string, data?: unknown, modal?: HttpModal | null): Promise<Msg<T>> {
|
||||||
|
if (modal) {
|
||||||
|
modal.loading(true);
|
||||||
|
}
|
||||||
|
const msg = await this.post<T>(url, data);
|
||||||
|
if (modal) {
|
||||||
|
modal.loading(false);
|
||||||
|
if (msg instanceof Msg && msg.success) {
|
||||||
|
modal.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDocumentTitle(): void {
|
||||||
|
const host = window.location.hostname;
|
||||||
|
if (!host) return;
|
||||||
|
const current = document.title.trim();
|
||||||
|
document.title = current ? `${host} - ${current}` : host;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PromiseUtil {
|
||||||
|
static async sleep(timeout: number): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, timeout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RandomSeqOptions {
|
||||||
|
type?: 'default' | 'hex';
|
||||||
|
hasNumbers?: boolean;
|
||||||
|
hasLowercase?: boolean;
|
||||||
|
hasUppercase?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RandomUtil {
|
||||||
|
static getSeq({ type = 'default', hasNumbers = true, hasLowercase = true, hasUppercase = true }: RandomSeqOptions = {}): string {
|
||||||
|
let seq = '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'hex':
|
||||||
|
seq += '0123456789abcdef';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (hasNumbers) seq += '0123456789';
|
||||||
|
if (hasLowercase) seq += 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
if (hasUppercase) seq += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomInteger(min: number, max: number): number {
|
||||||
|
const range = max - min + 1;
|
||||||
|
const randomBuffer = new Uint32Array(1);
|
||||||
|
window.crypto.getRandomValues(randomBuffer);
|
||||||
|
return Math.floor((randomBuffer[0] / (0xFFFFFFFF + 1)) * range) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomSeq(count: number, options: RandomSeqOptions = {}): string {
|
||||||
|
const seq = this.getSeq(options);
|
||||||
|
const seqLength = seq.length;
|
||||||
|
const randomValues = new Uint32Array(count);
|
||||||
|
window.crypto.getRandomValues(randomValues);
|
||||||
|
return Array.from(randomValues, (v) => seq[v % seqLength]).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomShortIds(): string {
|
||||||
|
const lengths = [2, 4, 6, 8, 10, 12, 14, 16].sort(() => Math.random() - 0.5);
|
||||||
|
return lengths.map((len) => this.randomSeq(len, { type: 'hex' })).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomLowerAndNum(len: number): string {
|
||||||
|
return this.randomSeq(len, { hasUppercase: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomUUID(): string {
|
||||||
|
if (window.location.protocol === 'https:') {
|
||||||
|
return window.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const randomValues = new Uint8Array(1);
|
||||||
|
window.crypto.getRandomValues(randomValues);
|
||||||
|
const randomValue = randomValues[0] % 16;
|
||||||
|
const calculatedValue = c === 'x' ? randomValue : (randomValue & 0x3) | 0x8;
|
||||||
|
return calculatedValue.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
|
||||||
|
let length = 32;
|
||||||
|
if (method === '2022-blake3-aes-128-gcm') {
|
||||||
|
length = 16;
|
||||||
|
}
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
window.crypto.getRandomValues(array);
|
||||||
|
return Base64.alternativeEncode(String.fromCharCode(...array));
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomBase64(length: number = 16): string {
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
window.crypto.getRandomValues(array);
|
||||||
|
return Base64.alternativeEncode(String.fromCharCode(...array));
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomBase32String(length: number = 16): string {
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
window.crypto.getRandomValues(array);
|
||||||
|
|
||||||
|
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
let result = '';
|
||||||
|
let bits = 0;
|
||||||
|
let buffer = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
buffer = (buffer << 8) | array[i];
|
||||||
|
bits += 8;
|
||||||
|
|
||||||
|
while (bits >= 5) {
|
||||||
|
bits -= 5;
|
||||||
|
result += base32Chars[(buffer >>> bits) & 0x1F];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bits > 0) {
|
||||||
|
result += base32Chars[(buffer << (5 - bits)) & 0x1F];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnyRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export class ObjectUtil {
|
||||||
|
static getPropIgnoreCase(obj: AnyRecord, prop: string): unknown {
|
||||||
|
for (const name in obj) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(obj, name)) continue;
|
||||||
|
if (name.toLowerCase() === prop.toLowerCase()) {
|
||||||
|
return obj[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
static deepSearch(obj: unknown, key: string): boolean {
|
||||||
|
if (obj instanceof Array) {
|
||||||
|
for (let i = 0; i < obj.length; ++i) {
|
||||||
|
if (this.deepSearch(obj[i], key)) return true;
|
||||||
|
}
|
||||||
|
} else if (obj instanceof Object) {
|
||||||
|
const rec = obj as AnyRecord;
|
||||||
|
for (const name in rec) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(rec, name)) continue;
|
||||||
|
if (this.deepSearch(rec[name], key)) return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return this.isEmpty(obj) ? false : String(obj).toLowerCase().indexOf(key.toLowerCase()) >= 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isEmpty(obj: unknown): boolean {
|
||||||
|
return obj === null || obj === undefined || obj === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static isArrEmpty(arr: unknown): boolean {
|
||||||
|
return !Array.isArray(arr) || arr.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static copyArr<T>(dest: T[], src: T[]): void {
|
||||||
|
dest.splice(0);
|
||||||
|
for (const item of src) {
|
||||||
|
dest.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone<T>(obj: T): T {
|
||||||
|
if (obj instanceof Array) {
|
||||||
|
const newArr: unknown[] = [];
|
||||||
|
this.copyArr(newArr, obj);
|
||||||
|
return newArr as unknown as T;
|
||||||
|
}
|
||||||
|
if (obj instanceof Object) {
|
||||||
|
const newObj: AnyRecord = {};
|
||||||
|
const rec = obj as unknown as AnyRecord;
|
||||||
|
for (const key of Object.keys(rec)) {
|
||||||
|
newObj[key] = rec[key];
|
||||||
|
}
|
||||||
|
return newObj as unknown as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
static deepClone<T>(obj: T): T {
|
||||||
|
if (obj instanceof Array) {
|
||||||
|
const newArr: unknown[] = [];
|
||||||
|
for (const item of obj) {
|
||||||
|
newArr.push(this.deepClone(item));
|
||||||
|
}
|
||||||
|
return newArr as unknown as T;
|
||||||
|
}
|
||||||
|
if (obj instanceof Object) {
|
||||||
|
const newObj: AnyRecord = {};
|
||||||
|
const rec = obj as unknown as AnyRecord;
|
||||||
|
for (const key of Object.keys(rec)) {
|
||||||
|
newObj[key] = this.deepClone(rec[key]);
|
||||||
|
}
|
||||||
|
return newObj as unknown as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
static cloneProps(dest: object, src: object, ...ignoreProps: string[]): void {
|
||||||
|
if (dest == null || src == null) return;
|
||||||
|
const ignoreEmpty = this.isArrEmpty(ignoreProps);
|
||||||
|
const d = dest as AnyRecord;
|
||||||
|
const s = src as AnyRecord;
|
||||||
|
for (const key of Object.keys(s)) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(s, key)) continue;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(d, key)) continue;
|
||||||
|
if (s[key] === undefined) continue;
|
||||||
|
if (ignoreEmpty) {
|
||||||
|
d[key] = s[key];
|
||||||
|
} else {
|
||||||
|
let ignore = false;
|
||||||
|
for (let i = 0; i < ignoreProps.length; ++i) {
|
||||||
|
if (key === ignoreProps[i]) {
|
||||||
|
ignore = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ignore) {
|
||||||
|
d[key] = s[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static delProps(obj: object, ...props: string[]): void {
|
||||||
|
const o = obj as AnyRecord;
|
||||||
|
for (const prop of props) {
|
||||||
|
if (prop in o) {
|
||||||
|
delete o[prop];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static execute(func: unknown, ...args: unknown[]): void {
|
||||||
|
if (!this.isEmpty(func) && typeof func === 'function') {
|
||||||
|
(func as (...a: unknown[]) => unknown)(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static orDefault<T>(obj: T | null | undefined, defaultValue: T): T {
|
||||||
|
if (obj == null) return defaultValue;
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
static equals(a: unknown, b: unknown): boolean {
|
||||||
|
if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') {
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
const ra = a as AnyRecord;
|
||||||
|
const rb = b as AnyRecord;
|
||||||
|
const aKeys = Object.keys(ra);
|
||||||
|
const bKeys = Object.keys(rb);
|
||||||
|
if (aKeys.length !== bKeys.length) return false;
|
||||||
|
for (const key of aKeys) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(rb, key)) return false;
|
||||||
|
if (ra[key] !== rb[key]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Wireguard {
|
||||||
|
static gf(init?: ArrayLike<number>): Float64Array {
|
||||||
|
const r = new Float64Array(16);
|
||||||
|
if (init) {
|
||||||
|
for (let i = 0; i < init.length; ++i) r[i] = init[i];
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
static pack(o: Uint8Array, n: Float64Array): void {
|
||||||
|
let b: number;
|
||||||
|
const m = this.gf();
|
||||||
|
const t = this.gf();
|
||||||
|
for (let i = 0; i < 16; ++i) t[i] = n[i];
|
||||||
|
this.carry(t);
|
||||||
|
this.carry(t);
|
||||||
|
this.carry(t);
|
||||||
|
for (let j = 0; j < 2; ++j) {
|
||||||
|
m[0] = t[0] - 0xffed;
|
||||||
|
for (let i = 1; i < 15; ++i) {
|
||||||
|
m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
|
||||||
|
m[i - 1] &= 0xffff;
|
||||||
|
}
|
||||||
|
m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1);
|
||||||
|
b = (m[15] >> 16) & 1;
|
||||||
|
m[14] &= 0xffff;
|
||||||
|
this.cswap(t, m, 1 - b);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 16; ++i) {
|
||||||
|
o[2 * i] = t[i] & 0xff;
|
||||||
|
o[2 * i + 1] = t[i] >> 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static carry(o: Float64Array): void {
|
||||||
|
for (let i = 0; i < 16; ++i) {
|
||||||
|
o[(i + 1) % 16] += (i < 15 ? 1 : 38) * Math.floor(o[i] / 65536);
|
||||||
|
o[i] &= 0xffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static cswap(p: Float64Array, q: Float64Array, b: number): void {
|
||||||
|
const c = ~(b - 1);
|
||||||
|
let t: number;
|
||||||
|
for (let i = 0; i < 16; ++i) {
|
||||||
|
t = c & (p[i] ^ q[i]);
|
||||||
|
p[i] ^= t;
|
||||||
|
q[i] ^= t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static add(o: Float64Array, a: Float64Array, b: Float64Array): void {
|
||||||
|
for (let i = 0; i < 16; ++i) o[i] = (a[i] + b[i]) | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static subtract(o: Float64Array, a: Float64Array, b: Float64Array): void {
|
||||||
|
for (let i = 0; i < 16; ++i) o[i] = (a[i] - b[i]) | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static multmod(o: Float64Array, a: Float64Array, b: Float64Array): void {
|
||||||
|
const t = new Float64Array(31);
|
||||||
|
for (let i = 0; i < 16; ++i) {
|
||||||
|
for (let j = 0; j < 16; ++j) t[i + j] += a[i] * b[j];
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 15; ++i) t[i] += 38 * t[i + 16];
|
||||||
|
for (let i = 0; i < 16; ++i) o[i] = t[i];
|
||||||
|
this.carry(o);
|
||||||
|
this.carry(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
static invert(o: Float64Array, i: Float64Array): void {
|
||||||
|
const c = this.gf();
|
||||||
|
for (let a = 0; a < 16; ++a) c[a] = i[a];
|
||||||
|
for (let a = 253; a >= 0; --a) {
|
||||||
|
this.multmod(c, c, c);
|
||||||
|
if (a !== 2 && a !== 4) this.multmod(c, c, i);
|
||||||
|
}
|
||||||
|
for (let a = 0; a < 16; ++a) o[a] = c[a];
|
||||||
|
}
|
||||||
|
|
||||||
|
static clamp(z: Uint8Array): void {
|
||||||
|
z[31] = (z[31] & 127) | 64;
|
||||||
|
z[0] &= 248;
|
||||||
|
}
|
||||||
|
|
||||||
|
static generatePublicKey(privateKey: Uint8Array): Uint8Array {
|
||||||
|
let r: number;
|
||||||
|
const z = new Uint8Array(32);
|
||||||
|
const a = this.gf([1]);
|
||||||
|
const b = this.gf([9]);
|
||||||
|
const c = this.gf();
|
||||||
|
const d = this.gf([1]);
|
||||||
|
const e = this.gf();
|
||||||
|
const f = this.gf();
|
||||||
|
const _121665 = this.gf([0xdb41, 1]);
|
||||||
|
const _9 = this.gf([9]);
|
||||||
|
for (let i = 0; i < 32; ++i) z[i] = privateKey[i];
|
||||||
|
this.clamp(z);
|
||||||
|
for (let i = 254; i >= 0; --i) {
|
||||||
|
r = (z[i >>> 3] >>> (i & 7)) & 1;
|
||||||
|
this.cswap(a, b, r);
|
||||||
|
this.cswap(c, d, r);
|
||||||
|
this.add(e, a, c);
|
||||||
|
this.subtract(a, a, c);
|
||||||
|
this.add(c, b, d);
|
||||||
|
this.subtract(b, b, d);
|
||||||
|
this.multmod(d, e, e);
|
||||||
|
this.multmod(f, a, a);
|
||||||
|
this.multmod(a, c, a);
|
||||||
|
this.multmod(c, b, e);
|
||||||
|
this.add(e, a, c);
|
||||||
|
this.subtract(a, a, c);
|
||||||
|
this.multmod(b, a, a);
|
||||||
|
this.subtract(c, d, f);
|
||||||
|
this.multmod(a, c, _121665);
|
||||||
|
this.add(a, a, d);
|
||||||
|
this.multmod(c, c, a);
|
||||||
|
this.multmod(a, d, f);
|
||||||
|
this.multmod(d, b, _9);
|
||||||
|
this.multmod(b, e, e);
|
||||||
|
this.cswap(a, b, r);
|
||||||
|
this.cswap(c, d, r);
|
||||||
|
}
|
||||||
|
this.invert(c, c);
|
||||||
|
this.multmod(a, a, c);
|
||||||
|
this.pack(z, a);
|
||||||
|
return z;
|
||||||
|
}
|
||||||
|
|
||||||
|
static generatePresharedKey(): Uint8Array {
|
||||||
|
const privateKey = new Uint8Array(32);
|
||||||
|
window.crypto.getRandomValues(privateKey);
|
||||||
|
return privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
static generatePrivateKey(): Uint8Array {
|
||||||
|
const privateKey = this.generatePresharedKey();
|
||||||
|
this.clamp(privateKey);
|
||||||
|
return privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
static encodeBase64(dest: Uint8Array, src: Uint8Array): void {
|
||||||
|
const input = Uint8Array.from([
|
||||||
|
(src[0] >> 2) & 63,
|
||||||
|
((src[0] << 4) | (src[1] >> 4)) & 63,
|
||||||
|
((src[1] << 2) | (src[2] >> 6)) & 63,
|
||||||
|
src[2] & 63,
|
||||||
|
]);
|
||||||
|
for (let i = 0; i < 4; ++i) {
|
||||||
|
dest[i] = input[i] + 65 +
|
||||||
|
(((25 - input[i]) >> 8) & 6) -
|
||||||
|
(((51 - input[i]) >> 8) & 75) -
|
||||||
|
(((61 - input[i]) >> 8) & 15) +
|
||||||
|
(((62 - input[i]) >> 8) & 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static keyToBase64(key: Uint8Array): string {
|
||||||
|
let i: number;
|
||||||
|
const base64 = new Uint8Array(44);
|
||||||
|
for (i = 0; i < 32 / 3; ++i) {
|
||||||
|
this.encodeBase64(base64.subarray(i * 4), key.subarray(i * 3));
|
||||||
|
}
|
||||||
|
this.encodeBase64(base64.subarray(i * 4), Uint8Array.from([key[i * 3 + 0], key[i * 3 + 1], 0]));
|
||||||
|
base64[43] = 61;
|
||||||
|
return String.fromCharCode.apply(null, Array.from(base64));
|
||||||
|
}
|
||||||
|
|
||||||
|
static keyFromBase64(encoded: string): Uint8Array {
|
||||||
|
const binaryStr = atob(encoded);
|
||||||
|
const bytes = new Uint8Array(binaryStr.length);
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateKeypair(secretKey: string = ''): { publicKey: string; privateKey: string } {
|
||||||
|
const privateKey = secretKey.length > 0 ? this.keyFromBase64(secretKey) : this.generatePrivateKey();
|
||||||
|
const publicKey = this.generatePublicKey(privateKey);
|
||||||
|
return {
|
||||||
|
publicKey: this.keyToBase64(publicKey),
|
||||||
|
privateKey: secretKey.length > 0 ? secretKey : this.keyToBase64(privateKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClipboardManager {
|
||||||
|
static async copyText(content: unknown = ''): Promise<boolean> {
|
||||||
|
const text = String(content ?? '');
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return ClipboardManager._legacyCopy(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
static _legacyCopy(text: string): boolean {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.setAttribute('aria-hidden', 'true');
|
||||||
|
textarea.style.position = 'absolute';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
textarea.style.opacity = '1';
|
||||||
|
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
const host = (active && active !== document.body && active.parentElement)
|
||||||
|
? active.parentElement
|
||||||
|
: document.body;
|
||||||
|
host.appendChild(textarea);
|
||||||
|
|
||||||
|
const sel0 = document.getSelection();
|
||||||
|
const prevSelection = sel0 && sel0.rangeCount ? sel0.getRangeAt(0) : null;
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
textarea.focus({ preventScroll: true });
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, text.length);
|
||||||
|
ok = document.execCommand('copy');
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
host.removeChild(textarea);
|
||||||
|
if (active && typeof active.focus === 'function') {
|
||||||
|
try { active.focus({ preventScroll: true }); } catch {}
|
||||||
|
}
|
||||||
|
if (prevSelection) {
|
||||||
|
const sel = document.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(prevSelection);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Base64 {
|
||||||
|
static encode(content: string = '', safe: boolean = false): string {
|
||||||
|
if (safe) {
|
||||||
|
return Base64.encode(content)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/=/g, '')
|
||||||
|
.replace(/\//g, '_');
|
||||||
|
}
|
||||||
|
return window.btoa(String.fromCharCode(...new TextEncoder().encode(content)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static alternativeEncode(content: string): string {
|
||||||
|
return window.btoa(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
static decode(content: string = ''): string {
|
||||||
|
return new TextDecoder().decode(
|
||||||
|
Uint8Array.from(window.atob(content), (c) => c.charCodeAt(0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SizeFormatter {
|
||||||
|
static readonly ONE_KB = 1024;
|
||||||
|
static readonly ONE_MB = SizeFormatter.ONE_KB * 1024;
|
||||||
|
static readonly ONE_GB = SizeFormatter.ONE_MB * 1024;
|
||||||
|
static readonly ONE_TB = SizeFormatter.ONE_GB * 1024;
|
||||||
|
static readonly ONE_PB = SizeFormatter.ONE_TB * 1024;
|
||||||
|
|
||||||
|
static sizeFormat(size: number | null | undefined): string {
|
||||||
|
if (size == null || size <= 0) return '0 B';
|
||||||
|
if (size < SizeFormatter.ONE_KB) return size.toFixed(0) + ' B';
|
||||||
|
if (size < SizeFormatter.ONE_MB) return (size / SizeFormatter.ONE_KB).toFixed(2) + ' KB';
|
||||||
|
if (size < SizeFormatter.ONE_GB) return (size / SizeFormatter.ONE_MB).toFixed(2) + ' MB';
|
||||||
|
if (size < SizeFormatter.ONE_TB) return (size / SizeFormatter.ONE_GB).toFixed(2) + ' GB';
|
||||||
|
if (size < SizeFormatter.ONE_PB) return (size / SizeFormatter.ONE_TB).toFixed(2) + ' TB';
|
||||||
|
return (size / SizeFormatter.ONE_PB).toFixed(2) + ' PB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CPUFormatter {
|
||||||
|
static cpuSpeedFormat(speed: number): string {
|
||||||
|
return speed > 1000 ? (speed / 1000).toFixed(2) + ' GHz' : speed.toFixed(2) + ' MHz';
|
||||||
|
}
|
||||||
|
|
||||||
|
static cpuCoreFormat(cores: number): string {
|
||||||
|
return cores === 1 ? '1 Core' : cores + ' Cores';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimeFormatter {
|
||||||
|
static formatSecond(second: number): string {
|
||||||
|
if (second < 60) return second.toFixed(0) + 's';
|
||||||
|
if (second < 3600) return (second / 60).toFixed(0) + 'm';
|
||||||
|
if (second < 3600 * 24) return (second / 3600).toFixed(0) + 'h';
|
||||||
|
const day = Math.floor(second / 3600 / 24);
|
||||||
|
const remain = Number(((second / 3600) - (day * 24)).toFixed(0));
|
||||||
|
return day + 'd' + (remain > 0 ? ' ' + remain + 'h' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NumberFormatter {
|
||||||
|
static addZero(num: number): string | number {
|
||||||
|
return num < 10 ? '0' + num : num;
|
||||||
|
}
|
||||||
|
|
||||||
|
static toFixed(num: number, n: number): number {
|
||||||
|
const m = Math.pow(10, n);
|
||||||
|
return Math.floor(num * m) / m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Utils {
|
||||||
|
static debounce<A extends unknown[]>(fn: (...args: A) => unknown, delay: number): (...args: A) => void {
|
||||||
|
let timeoutID: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
return function (this: unknown, ...args: A) {
|
||||||
|
if (timeoutID !== null) clearTimeout(timeoutID);
|
||||||
|
timeoutID = setTimeout(() => fn.apply(this, args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CookieManager {
|
||||||
|
static getCookie(cname: string): string {
|
||||||
|
const name = cname + '=';
|
||||||
|
const ca = document.cookie.split(';');
|
||||||
|
for (let c of ca) {
|
||||||
|
c = c.trim();
|
||||||
|
if (c.indexOf(name) === 0) {
|
||||||
|
return decodeURIComponent(c.substring(name.length, c.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static setCookie(cname: string, cvalue: string, exdays?: number): void {
|
||||||
|
let expires = '';
|
||||||
|
if (exdays) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
|
||||||
|
expires = 'expires=' + d.toUTCString() + ';';
|
||||||
|
}
|
||||||
|
document.cookie = cname + '=' + encodeURIComponent(cvalue) + ';' + expires + 'path=/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
success: '#389e0a',
|
||||||
|
warning: '#faad14',
|
||||||
|
danger: '#ff4d4f',
|
||||||
|
purple: '#722ed1',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UsageColor = 'purple' | 'green' | 'orange' | 'red';
|
||||||
|
|
||||||
|
export interface ClientUsageStats {
|
||||||
|
total: number;
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpiryClient {
|
||||||
|
enable: boolean;
|
||||||
|
expiryTime: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ColorUtils {
|
||||||
|
static usageColor(
|
||||||
|
data: number | null | undefined,
|
||||||
|
threshold: number,
|
||||||
|
total: number | { valueOf(): number } | null | undefined,
|
||||||
|
): UsageColor {
|
||||||
|
const t = Number(total ?? 0);
|
||||||
|
const d = Number(data);
|
||||||
|
switch (true) {
|
||||||
|
case data === null || data === undefined: return 'purple';
|
||||||
|
case t < 0: return 'green';
|
||||||
|
case t == 0: return 'purple';
|
||||||
|
case d < t - threshold: return 'green';
|
||||||
|
case d < t: return 'orange';
|
||||||
|
default: return 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static clientUsageColor(clientStats: ClientUsageStats | null | undefined, trafficDiff: number): string {
|
||||||
|
switch (true) {
|
||||||
|
case !clientStats || clientStats.total == 0: return COLORS.purple;
|
||||||
|
case clientStats!.up + clientStats!.down < clientStats!.total - trafficDiff: return COLORS.success;
|
||||||
|
case clientStats!.up + clientStats!.down < clientStats!.total: return COLORS.warning;
|
||||||
|
default: return COLORS.danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static userExpiryColor(threshold: number, client: ExpiryClient, isDark: boolean = false): string {
|
||||||
|
if (!client.enable) return isDark ? '#2c3950' : '#bcbcbc';
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const expiry = client.expiryTime;
|
||||||
|
switch (true) {
|
||||||
|
case expiry === null: return COLORS.purple;
|
||||||
|
case (expiry as number) < 0: return COLORS.success;
|
||||||
|
case (expiry as number) == 0: return COLORS.purple;
|
||||||
|
case now < (expiry as number) - threshold: return COLORS.success;
|
||||||
|
case now < (expiry as number): return COLORS.warning;
|
||||||
|
default: return COLORS.danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ArrayUtils {
|
||||||
|
static doAllItemsExist<T>(array1: T[], array2: T[]): boolean {
|
||||||
|
return array1.every((item) => array2.includes(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildURLOptions {
|
||||||
|
host?: string;
|
||||||
|
port?: string;
|
||||||
|
isTLS?: boolean;
|
||||||
|
base: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class URLBuilder {
|
||||||
|
static buildURL({ host, port, isTLS, base, path }: BuildURLOptions): string {
|
||||||
|
if (!host || host.length === 0) host = window.location.hostname;
|
||||||
|
if (!port || port.length === 0) port = window.location.port;
|
||||||
|
if (isTLS === undefined) isTLS = window.location.protocol === 'https:';
|
||||||
|
|
||||||
|
const protocol = isTLS ? 'https:' : 'http:';
|
||||||
|
let portPart = String(port);
|
||||||
|
if (portPart === '' || (isTLS && portPart === '443') || (!isTLS && portPart === '80')) {
|
||||||
|
portPart = '';
|
||||||
|
} else {
|
||||||
|
portPart = `:${portPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}//${host}${portPart}${base}${path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportedLanguage {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LanguageManager {
|
||||||
|
static readonly supportedLanguages: readonly SupportedLanguage[] = [
|
||||||
|
{ name: 'العربية', value: 'ar-EG', icon: '🇪🇬' },
|
||||||
|
{ name: 'English', value: 'en-US', icon: '🇺🇸' },
|
||||||
|
{ name: 'فارسی', value: 'fa-IR', icon: '🇮🇷' },
|
||||||
|
{ name: '简体中文', value: 'zh-CN', icon: '🇨🇳' },
|
||||||
|
{ name: '繁體中文', value: 'zh-TW', icon: '🇹🇼' },
|
||||||
|
{ name: '日本語', value: 'ja-JP', icon: '🇯🇵' },
|
||||||
|
{ name: 'Русский', value: 'ru-RU', icon: '🇷🇺' },
|
||||||
|
{ name: 'Tiếng Việt', value: 'vi-VN', icon: '🇻🇳' },
|
||||||
|
{ name: 'Español', value: 'es-ES', icon: '🇪🇸' },
|
||||||
|
{ name: 'Indonesian', value: 'id-ID', icon: '🇮🇩' },
|
||||||
|
{ name: 'Український', value: 'uk-UA', icon: '🇺🇦' },
|
||||||
|
{ name: 'Türkçe', value: 'tr-TR', icon: '🇹🇷' },
|
||||||
|
{ name: 'Português', value: 'pt-BR', icon: '🇧🇷' },
|
||||||
|
];
|
||||||
|
|
||||||
|
static getLanguage(): string {
|
||||||
|
let lang = CookieManager.getCookie('lang');
|
||||||
|
if (lang) return lang;
|
||||||
|
|
||||||
|
if (window.navigator) {
|
||||||
|
const nav = window.navigator as Navigator & { userLanguage?: string };
|
||||||
|
lang = nav.language || nav.userLanguage || '';
|
||||||
|
|
||||||
|
const simularLangs: [string, string][] = [
|
||||||
|
['ar', LanguageManager.supportedLanguages[0].value],
|
||||||
|
['fa', LanguageManager.supportedLanguages[2].value],
|
||||||
|
['ja', LanguageManager.supportedLanguages[5].value],
|
||||||
|
['ru', LanguageManager.supportedLanguages[6].value],
|
||||||
|
['vi', LanguageManager.supportedLanguages[7].value],
|
||||||
|
['es', LanguageManager.supportedLanguages[8].value],
|
||||||
|
['id', LanguageManager.supportedLanguages[9].value],
|
||||||
|
['uk', LanguageManager.supportedLanguages[10].value],
|
||||||
|
['tr', LanguageManager.supportedLanguages[11].value],
|
||||||
|
['pt', LanguageManager.supportedLanguages[12].value],
|
||||||
|
];
|
||||||
|
|
||||||
|
simularLangs.forEach((pair) => {
|
||||||
|
if (lang === pair[0]) {
|
||||||
|
lang = pair[1];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (LanguageManager.isSupportLanguage(lang)) {
|
||||||
|
CookieManager.setCookie('lang', lang);
|
||||||
|
} else {
|
||||||
|
CookieManager.setCookie('lang', 'en-US');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CookieManager.setCookie('lang', 'en-US');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
static setLanguage(language: string): void {
|
||||||
|
if (!LanguageManager.isSupportLanguage(language)) {
|
||||||
|
language = 'en-US';
|
||||||
|
}
|
||||||
|
CookieManager.setCookie('lang', language);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
static isSupportLanguage(language: string): boolean {
|
||||||
|
return LanguageManager.supportedLanguages.some((lang) => lang.value === language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileManager {
|
||||||
|
static downloadTextFile(content: BlobPart, filename: string = 'file.txt', options: BlobPropertyBag = { type: 'text/plain' }): void {
|
||||||
|
const link = window.document.createElement('a');
|
||||||
|
link.download = filename;
|
||||||
|
link.style.border = '0';
|
||||||
|
link.style.padding = '0';
|
||||||
|
link.style.margin = '0';
|
||||||
|
link.style.position = 'absolute';
|
||||||
|
link.style.left = '-9999px';
|
||||||
|
link.style.top = `${window.pageYOffset || window.document.documentElement.scrollTop}px`;
|
||||||
|
link.href = URL.createObjectURL(new Blob([content], options));
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(link.href);
|
||||||
|
link.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalendarKind = 'gregorian' | 'jalalian';
|
||||||
|
|
||||||
|
export class IntlUtil {
|
||||||
|
static formatDate(date: string | number | Date | null | undefined, calendar: CalendarKind = 'gregorian'): string {
|
||||||
|
if (date == null) return '';
|
||||||
|
const language = LanguageManager.getLanguage();
|
||||||
|
const locale = calendar === 'jalalian' ? 'fa-IR' : language;
|
||||||
|
|
||||||
|
const intlOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const intl = new Intl.DateTimeFormat(locale, intlOptions);
|
||||||
|
return intl.format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
static formatRelativeTime(date: number | null | undefined): string {
|
||||||
|
if (date == null) return '';
|
||||||
|
const language = LanguageManager.getLanguage();
|
||||||
|
const now = new Date();
|
||||||
|
const diff = date < 0
|
||||||
|
? Math.round(date / (1000 * 60 * 60 * 24))
|
||||||
|
: Math.round((date - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const formatter = new Intl.RelativeTimeFormat(language, { numeric: 'auto' });
|
||||||
|
return formatter.format(diff, 'day');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -203,6 +203,11 @@ export default defineConfig({
|
||||||
|| id.includes('/node_modules/swagger-ui/')
|
|| id.includes('/node_modules/swagger-ui/')
|
||||||
|| id.includes('/node_modules/swagger-client/')
|
|| id.includes('/node_modules/swagger-client/')
|
||||||
) return 'vendor-swagger';
|
) return 'vendor-swagger';
|
||||||
|
if (
|
||||||
|
id.includes('/node_modules/recharts/')
|
||||||
|
|| id.includes('/node_modules/victory-vendor/')
|
||||||
|
|| id.includes('/node_modules/d3-')
|
||||||
|
) return 'vendor-recharts';
|
||||||
if (id.includes('dayjs')) return 'vendor-dayjs';
|
if (id.includes('dayjs')) return 'vendor-dayjs';
|
||||||
if (id.includes('axios')) return 'vendor-axios';
|
if (id.includes('axios')) return 'vendor-axios';
|
||||||
return 'vendor';
|
return 'vendor';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue