Compare commits

...

80 Commits

Author SHA1 Message Date
sagit db3577afa9 feat(frontend): improve favicon loading with fallback to config API (#221)
## Summary
- Add synchronous config API call in index.html when localStorage cache
is empty
- Prevents favicon flash on login page during first load
- Enhance getCachedConfigs() to fetch public configs as fallback
- Preserve existing siteConfig values when config keys are missing
2026-02-27 18:25:16 +08:00
sagitchu 7285717e34 feat(frontend): improve favicon loading with fallback to config API
- Add synchronous config API call in index.html when localStorage cache is empty
- Prevents favicon flash on login page during first load
- Enhance getCachedConfigs() to fetch public configs as fallback
- Preserve existing siteConfig values when config keys are missing
2026-02-27 18:23:18 +08:00
sagit de6911f219 feat: add brand asset upload with PNG conversion and improve config validation (#220)
## Summary
- Add file upload support for logo and favicon with automatic PNG
conversion (96x96 for logo, 64x64 for favicon)
- Add backend validation for brand asset data URLs (app_logo,
app_favicon)
- Change vite_config.value column type from varchar(200) to text for
PostgreSQL compatibility
- Add schema migration v3 for vite_config.value column type conversion
- Update frontend to use file picker instead of manual URL input
- Add early favicon application in index.html to prevent flash

## Changes
- Backend: Validate brand asset data URLs, support larger config values
- Frontend: File upload with PNG conversion, improved caching
- Migration: PostgreSQL vite_config.value column type migration

## Testing
- Backend contract tests added for migration v3
2026-02-27 15:56:14 +08:00
sagitchu e5ce0501a2 feat: add brand asset upload with PNG conversion and improve config validation
- Add file upload support for logo and favicon with automatic PNG conversion
- Add backend validation for brand asset data URLs (app_logo, app_favicon)
- Change vite_config.value column type from varchar(200) to text for PostgreSQL
- Add schema migration v3 for vite_config.value column type conversion
- Update frontend to use file picker instead of manual URL input
- Add early favicon application in index.html to prevent flash
2026-02-27 15:54:04 +08:00
sagit 25a87e25c5 fix(backend): improve captcha validation and forward service sync (#219)
## Summary
- Add cloudflare site/secret key validation for captcha enabled check to
prevent incomplete captcha config
- Fix forward create to use UpdateService with tolerateExists for
idempotent service sync
- Introduce isAlreadyExistsMessage helper that correctly excludes
"address already in use" errors from being tolerated
- Add contract tests for forward toggle (pause/resume), address-in-use
rollback, and captcha compatibility

## Test Plan
- Contract tests added: `TestForwardCreateThenPauseResumeContract`,
`TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract`,
`TestIsAlreadyExistsMessage`
- Captcha login flow tests updated for cloudflare key validation
2026-02-27 14:52:14 +08:00
sagitchu a628f31859 fix(backend): improve captcha validation and forward service sync
- Add cloudflare site/secret key validation for captcha enabled check
- Fix forward create to use UpdateService with tolerateExists for idempotent sync
- Introduce isAlreadyExistsMessage helper excluding address-in-use errors
- Add contract tests for forward toggle, address-in-use rollback, captcha compatibility
2026-02-27 14:49:59 +08:00
sagitchu aae138a8cf fix(forward): remove placeholder from speed limit select, default to no limit 2026-02-27 14:49:59 +08:00
sagit d2645589da feat(frontend): add customizable favicon and corner logo with live preview (#218)
* feat(frontend): add customizable favicon and corner logo with live preview

- Add app_logo and app_favicon config fields in config page
- Implement BrandLogo component with URL fallback to SVG logo
- Integrate BrandLogo into all layouts (admin, h5, h5-simple) and navbar
- Add real-time preview for favicon and logo in config page with error state
- Support both relative paths and full image URLs for branding assets
- Sync branding changes (app_name/app_logo/app_favicon) to site config on save

* fix(frontend): preserve tunnel node selection order

* fix(select): use useMemo for option label map to improve performance
2026-02-27 11:50:03 +08:00
sagit 6684a3426b fix(frontend): use placeholder for IP preference select (#217)
## Summary
- Replace empty key SelectItem with proper placeholder for IP preference
dropdown
- Improves UX consistency with other select components
2026-02-27 08:39:26 +08:00
sagitchu 7a8595ec87 fix(frontend): use placeholder for IP preference select instead of empty key item 2026-02-27 08:33:10 +08:00
sagitchu 06f76d918f fix(frontend): unify speed rule placeholders in selects 2026-02-27 08:33:10 +08:00
sagit feb357ff17 fix: tunnel chain order and speed limit UI improvements (#216)
* fix(backend): use correct chain order index for tunnel nodes

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(frontend): filter '不限速' from speed limit dropdowns and preserve node order

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-26 21:30:25 +08:00
sagit 34581e0d18 feat: decouple speed limits from tunnels and add forward-level rate limiting (#214)
## Summary

This PR refactors the speed limiting functionality to decouple it from
tunnel-specific binding and adds support for forward-level rate
limiting.

### Key Changes

**Backend (Go)**:
- `SpeedLimit.TunnelID` and `SpeedLimit.TunnelName` are now nullable,
allowing speed limits to be created without binding to a specific tunnel
- `Forward` model now has `SpeedID sql.NullInt64` field for
forward-level rate limiting
- `ForwardRecord` updated to include `SpeedID` for control plane use
- Repository methods updated to handle optional tunnel binding in CRUD
operations
- Control plane now prioritizes `Forward.SpeedID` over
`UserTunnel.SpeedID`

**Frontend (React/TypeScript)**:
- Updated `limit.tsx` to support creating speed limits without tunnel
binding
- Tunnel selection is now optional in the speed limit form
- Updated TypeScript types for optional `tunnelId` and new `speedId`
fields

### Behavior

**Speed Limit Application Priority**:
1. `Forward.SpeedID` - Forward-level rate limiting (highest priority)
2. `UserTunnel.SpeedID` - User tunnel permission-level rate limiting
(fallback)

### Migration Notes

Database schema changes will be handled automatically by GORM
AutoMigrate:
- `speed_limit.tunnel_id` and `speed_limit.tunnel_name` become nullable
- `forward.speed_id` column added (nullable)

### Docker Images

Built and pushed:
- `ghcr.io/sagit-chu/vite-frontend:beta`
- `ghcr.io/sagit-chu/vite-frontend:latest`
- `ghcr.io/sagit-chu/flux-panel-backend:beta`
- `ghcr.io/sagit-chu/flux-panel-backend:latest`

closes #201 #155
2026-02-26 20:20:11 +08:00
sagitchu 61c5b5e759 feat: add speed limit contract tests and refine limit/user UI 2026-02-26 20:18:29 +08:00
sagitchu c8eb780c67 feat: decouple speed limits from tunnels and add forward-level rate limiting
- Make SpeedLimit.TunnelID and TunnelName nullable (optional binding)
- Add SpeedID field to Forward model for forward-level rate limiting
- Update ForwardRecord to include SpeedID for control plane
- Update repository methods to handle optional tunnel binding
- Update handlers to accept optional tunnelId in create/update
- Modify control plane to prioritize Forward.SpeedID over UserTunnel speed limit
- Update frontend limit.tsx to support creating speed limits without tunnel binding
- Update TypeScript types for optional tunnelId and new speedId fields

This allows speed limits to be created as reusable rules that can be applied
to either tunnels (via UserTunnel.SpeedID) or individual forwards (via Forward.SpeedID).
2026-02-26 13:08:35 +08:00
sagit 4bdfa50b0c docs: update AGENTS.md files with current project state (#213)
- Update root AGENTS.md to commit 21008cc / tag 2.1.5-rc15
- Add CI workflows info (ci-build.yml, docker-build.yml, deploy-docs.yml)
- Add Repository Layer and Contract Tests to WHERE TO LOOK
- Add websocket_reporter to CODE MAP
- Add Go version conventions (1.24/1.23/1.22)
- Add PostgreSQL migration support note
- Update go-backend AGENTS.md with PostgreSQL support and contract tests
- Update go-gost AGENTS.md with CI build conventions
- Update vite-frontend AGENTS.md with component counts
- Update go-gost/x AGENTS.md with file counts and registry reference
- Update handler AGENTS.md with LOC estimates
2026-02-26 09:52:11 +08:00
sagit 21008ccb43 fix: resolve federation forward card showing zero flow (#212)
## Summary

Fixed federation port-forward tunnels not displaying traffic on forward
cards.

**Root Cause:**
- Frontend's mergeFederationShareFlow only parsed shareId from tunnel
name (Share-{id}-Port-{port} format)
- Federation tunnels with custom names couldn't be resolved, causing
traffic display to show 0

**Fix:**
- Added fallback to resolve shareId via remote-usage bindings[].tunnelId
- Multiple resolution candidates are merged with max(currentFlow)
selection
- Maintains directFlow precedence over federation share flow

**Tests:**
- Added contract test for custom tunnel name with binding-based
resolution
- All existing tests pass
- Frontend builds successfully

Fixes: federation port-forward showing total flow 0
2026-02-25 21:24:50 +08:00
sagitchu 362d327bf9 fix: resolve federation forward card showing zero flow
- Fixed mergeFederationShareFlow to resolve shareId via tunnel binding
  when tunnel name is not in Share-{id}-Port-{port} format
- Added fallback to parse shareId from remote-usage bindings[].tunnelId
- Added contract test for custom tunnel name with binding-based resolution
- All tests pass, frontend builds successfully

Fixes federation port-forward tunnels with custom names not displaying
traffic on forward cards.
2026-02-25 21:21:46 +08:00
sagit 9a650fcc8f release: 2.1.5-rc14 - Federation forward flow linkage enhancement (#210)
## Summary
- Enhanced federation forward flow stats with local peer share support
- Merged local and remote flow usage tracking
- Added contract test for federation forward card flow linkage

## Changes
- `vite-frontend/src/pages/forward.tsx`: Integrated `getPeerShareList`
API
- `go-backend/tests/contract/`: Added federation forward flow linkage
contract test

## Testing
- Contract test added and verified
2026-02-25 20:36:54 +08:00
sagitchu 804a5a29ea feat: enhance federation forward flow linkage with peer share support
- Add getPeerShareList API integration in forward.tsx
- Merge local peer share flow with remote usage stats
- Add contract test for federation forward card flow linkage
- Ensure max current flow is tracked across both local and remote sources
2026-02-25 20:35:33 +08:00
sagit 6189fe23f1 feat: include forward ports in federation remote usage and display share flow (#209)
* feat(backend): include forward ports in federation remote usage list

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(frontend): display federation share flow in forward list

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-25 16:59:39 +08:00
sagit 7ba90e8696 fix(backend): resolve federation forward traffic stats and listener disappearance (#208)
* fix(backend): add repository methods for federation forward runtime management

- GetActiveForwardPeerShareRuntimeByServiceName: lookup runtime by share_id and service_name
- MarkForwardPeerShareRuntimeReleasedByServiceName: release runtime by service_name
- ListActiveForwardPeerShareRuntimesByNodeAndServiceName: node-scoped query for flow processing

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(backend): bind and release federation forward runtimes on service commands

- bindPeerShareForwardRuntimeServices: create runtime if missing, update ServiceName/Port/Applied/Status
- releasePeerShareForwardRuntimeServices: handle deleteservice command to mark runtime released
- parseFederationForwardServiceNamesForRelease: extract service names from delete payload
- Tests: bind creates runtime when missing, release marks runtime as released

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(backend): scope federation flow lookup by node to avoid cross-share collisions

- flowUpload: use GetNodeBySecret to extract nodeID for flow processing
- processFlowItem: accept nodeID parameter and pass to flow handlers
- processPeerShareFlowByServiceName: try node-scoped query first, fallback to global
- Add warning log when multiple runtimes match (ambiguous)
- Tests: update all processFlowItem calls with nodeID parameter

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(contract): adjust federation dual panel contract expectations

Update assertion for entry share runtime binding behavior after fix

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-25 14:10:24 +08:00
sagit 0eed74fe10 fix: stabilize federation forward runtime cleanup and frontend version UX (#207)
## Summary
- fix federation forward runtime cleanup so valid forward listeners are
not treated as orphan during unbound binding windows
- add backend regression coverage for federation forward/runtime cleanup
and expiry handling paths
- wire frontend version update footer/flows and related UI updates
across admin pages

## Verification
- go test ./...
- make build
2026-02-25 11:22:12 +08:00
sagitchu 466cc65069 fix: stabilize federation forward runtime cleanup and wire frontend version update UX 2026-02-25 11:20:51 +08:00
sagit 9c41410f17 fix(backend): handle federation service arrays in runtime command (#206)
## Summary
- accept both top-level service arrays and wrapped `services` payloads
in federation runtime command parsing
- fix forward runtime service binding and port-range validation for
remote shared-node AddService/UpdateService calls
- add regression tests for top-level array payload handling to prevent
listener cleanup/flow mapping regressions

## Verification
- go test ./internal/http/handler -run
"TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray|TestValidateFederationCommandPortsAcceptsTopLevelServiceArray|TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole|TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices|TestProcessFlowItemTracksPeerShareFlowByForwardServiceName"
- go test ./...
- make build
2026-02-25 00:08:32 +08:00
sagitchu bc71c524e0 fix(backend): accept top-level federation service payloads 2026-02-25 00:06:31 +08:00
sagit f46b2b4d86 fix(backend): keep shared federation port-forward listeners alive (#205)
## Summary
- prevent shared federation forward services from being misclassified as
orphaned during node config cleanup
- harden peer-share runtime mapping for service-name based flow
accounting and node/port conflict checks
- add regression tests for listener persistence, cleanup guards, runtime
binding, and federation port-forward lifecycle

## Verification
- go test ./internal/http/handler -run
\"TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices|TestCleanOrphanedServicesSkipsFederationServicePrefix|TestProcessFlowItemTracksPeerShareFlowByForwardServiceName|TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous|TestFederationTunnelCreateRejectsOccupiedPort\"
- go test ./...
- make build
2026-02-24 23:08:02 +08:00
sagitchu 9f17d63cdc fix(backend): keep shared federation port-forward services stable 2026-02-24 23:06:09 +08:00
sagit 92f8ec47db fix(backend): track traffic stats for federation port-forward tunnels (#204)
## Summary

- Fixed traffic statistics not being tracked for federation port-forward
tunnels (tunnelType=1) in Panel Peering mode
- Added `parsePeerShareIDFromFederationTunnelName()` to extract shareID
from tunnel names matching `Share-{shareId}-Port-{port}` pattern
- Added `processPeerShareFlowFromForward()` to update
`peer_share.current_flow` when forward traffic belongs to a federation
port-forward tunnel
- Added regression test
`TestProcessFlowItemTracksPeerShareFlowForFederationPortForward`

## Root Cause

Federation + tunnel type=2 (隧道转发) traffic uses service names
`fed_svc_{runtimeID}` → correctly routed to `processPeerShareFlow()` →
`peer_share.current_flow` updated.

Federation + tunnel type=1 (端口转发) traffic uses regular forward service
names `{forwardId}_{userId}_{userTunnelId}` → only `AddFlow()` called →
`peer_share.current_flow` NOT updated → shared flow stats remain 0.

## Test Plan

- [x] `go test ./internal/http/handler -run
'TestProcessFlowItemTracksPeerShareFlow'` passes
- [x] `go test ./internal/http/handler` passes
- [x] `go test ./...` passes
- [x] `make build` succeeds
2026-02-24 20:41:18 +08:00
sagitchu a97484cd9b fix(backend): track traffic stats for federation port-forward tunnels
Federation mode panel peering with port-forward tunnel type (tunnelType=1)
was not updating peer_share.current_flow because regular forward traffic
uses different service name pattern than federation tunnel traffic.

Added parsePeerShareIDFromFederationTunnelName() to extract shareID from
tunnel names matching 'Share-{shareId}-Port-{port}' pattern, and
processPeerShareFlowFromForward() to update peer_share flow stats when
the forward belongs to a federation port-forward tunnel.
2026-02-24 20:25:04 +08:00
sagit ee6bc8c50e fix(frontend): keep mobile batch toolbar right-aligned (#203)
## Summary
- keep mobile batch action bars visually right-aligned while preserving
Android horizontal swipe behavior
- move overflow handling to an outer scroller and keep action layout in
an inner `min-w-full` flex row
- apply the same structure across forward, node, and tunnel pages for
consistent behavior

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint -- src/pages/forward.tsx src/pages/node.tsx
src/pages/tunnel.tsx` *(fails due to pre-existing a11y labels in
`forward.tsx` lines 3202/3223)*
2026-02-23 22:55:49 +08:00
sagitchu c94ab84ab9 fix(frontend): keep mobile batch toolbar right-aligned
Use an overflow wrapper with an inner min-w-full flex row so controls stay visually right-aligned when space is sufficient, while Android can still horizontally swipe when the toolbar overflows.
2026-02-23 22:53:48 +08:00
sagit 84a03215f4 fix(frontend): restore Android swipe for mobile batch toolbar (#202)
## Summary
- fix mobile batch action toolbar in forward/node/tunnel pages to keep
horizontal overflow reachable on Android
- switch toolbar alignment to start on mobile and keep right alignment
on `sm+` to avoid `justify-end` overflow reachability issues
- add `touch-pan-x` to improve horizontal swipe handling on mobile
browsers while preserving existing desktop layout

## Verification
- `npm run build` *(fails due to pre-existing issue: `src/main.tsx`
cannot find module `virtual:pwa-register`)*
- `npm run lint` *(fails due to pre-existing a11y errors in unrelated
files and in existing forward labels)*
2026-02-23 22:26:11 +08:00
sagitchu def93749eb fix(frontend): restore Android swipe for mobile batch toolbar
Switch mobile batch toolbar alignment to start and enable horizontal pan gestures so overflow actions remain reachable on Android while desktop right alignment stays unchanged.
2026-02-23 22:24:28 +08:00
sagit 945a1c0dfc fix(frontend): remove border from batch toggle button (#199) 2026-02-23 22:03:29 +08:00
sagit d752e096a3 fix(frontend): move batch operations into top toolbar (#198)
## Summary
- switch batch mode interactions to the top-right action bar on forward,
tunnel, and node pages
- remove bottom floating batch toolbars and restore normal action
toolbar when exiting batch mode
- stabilize toolbar layout and improve batch button visibility to avoid
visual jitter on toggle
2026-02-23 21:30:57 +08:00
sagitchu 880a3b81b0 fix(frontend): move batch actions into top toolbar
Switching batch controls to the top-right action bar with stable single-row layout improves clarity and prevents visual jumps while entering batch mode.
2026-02-23 21:28:50 +08:00
sagit 191aface2e fix(frontend): extend h5 tabbar safe-area coverage (#197)
Ensure the PWA bottom tab bar and tab hit areas fully cover the safe-area inset while keeping related frontend formatting updates consistent.
2026-02-23 07:45:14 +00:00
sagit e121dadb90 fix(backend): allow WHMCS API login when captcha is enabled (#196)
## Summary
- bypass captcha verification for machine API clients tagged as WHMCS
while keeping captcha enforcement for normal login flows
- add a backend contract test that verifies WHMCS-tagged login succeeds
when captcha is enabled
- keep WHMCS module repository changes out of this PR so only backend
behavior is merged to main
2026-02-23 14:42:24 +08:00
sagitchu bafcfbde3a fix(backend): allow WHMCS API login when captcha is enabled 2026-02-23 14:40:29 +08:00
sagit 98c463c62b feat(frontend): add installable PWA support and refresh app icons (#195) 2026-02-23 06:26:29 +00:00
sagit daf34d0f6c fix(backend): prevent login block when captcha enabled without cloudflare key (#194)
When captcha_enabled=true but cloudflare_secret_key is not configured,
the login flow would block users with "未配置Cloudflare Site Key" error.
Now captcha is treated as disabled if the secret key is missing, allowing
users to log in normally on fresh PostgreSQL installations.

Fixes login issue on new panel setups with PostgreSQL.
2026-02-22 22:54:51 +08:00
sagit bb505d461d fix(frontend): enable modal scroll on panel sharing page (#193)
Add scrollBehavior="inside" to Create Share and Edit Share modals
to allow content scrolling on mobile devices when form fields
exceed viewport height.
2026-02-22 21:53:29 +08:00
sagit 00be0ac31e fix(frontend): enable content scroll on mobile (#192)
Main content area had overflow-hidden on mobile which prevented
scrolling when content exceeded viewport height. Changed to
overflow-y-auto for consistent scroll behavior across all devices.
2026-02-22 21:12:45 +08:00
sagit fc5624a190 fix(frontend): modal footer buttons visible on mobile (#190)
ModalFooter used flex-col-reverse which caused buttons to display in
reverse order on mobile and potentially pushed cancel button out of
view. Changed to flex-wrap justify-end for consistent cross-platform
button display.
2026-02-22 20:05:02 +08:00
sagit 42ae3457b5 feat(frontend): persist filter state across page reloads (#189)
* feat(frontend): persist filter state across page reloads

Add useLocalStorageState hook to persist filter/search state in
forward, node, tunnel, user, and limit pages. Reset filter clears
the persisted value from localStorage.

* feat(ui): add collapsible sidebar and smooth animations
2026-02-22 19:01:03 +08:00
sagit 088027da7b fix(frontend): resolve remaining font blur in node and forward cards (#188)
## Summary
- apply the same anti-blur drag transform strategy used in tunnel cards
to node cards
- update forward card and table-row sortable transforms to round x/y
pixels and reduce subpixel text blur
- limit `willChange` usage to active dragging so text does not remain on
a promoted layer while idle

## Verification
- npm run build (vite-frontend)
2026-02-22 16:55:08 +08:00
sagitchu d37adee5df fix(frontend): resolve remaining font blur in node and forward cards 2026-02-22 16:53:35 +08:00
sagit c147e52d72 fix(frontend): ship pending card-view animation and interaction refinements (#186)
## Summary
- include the full pending frontend refinements across card views,
search/filter interactions, and bridge components
- keep sortable/card animation behavior aligned with the latest
anti-blur adjustments in tunnel card rendering
- bundle related UI consistency updates across tunnel, forward, node,
user, and dashboard pages

## Verification
- npm run build (vite-frontend)
2026-02-22 15:54:30 +08:00
sagitchu d483258eef fix(frontend): ship pending card-view animation and interaction refinements 2026-02-22 15:52:48 +08:00
sagit 79c28103d5 fix(frontend): prevent font blur in sortable card components (#185)
Add backface-visibility: hidden and font-smoothing properties to SortableItem components in tunnel, forward, and node pages to prevent GPU subpixel rendering blur during drag operations.

- tunnel.tsx: SortableItem wrapper anti-blur fix
- forward.tsx: SortableCard wrapper anti-blur fix
- node.tsx: SortableItem component anti-blur fix

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-22 15:11:47 +08:00
sagit f36bf1437c fix(frontend): prevent font blurriness caused by scale animations (#183)
Remove scale transforms from Framer Motion animations that cause subpixel rendering issues on text elements. Replace with opacity + translateY for smooth animations without blur.

- Remove scale from FadeIn component (animated-page.tsx)
- Remove scale from login page entrance animation (index.tsx)
- Remove scale from search bar button animation (search-bar.tsx)
- Remove whileTap scale from sidebar menu buttons (admin.tsx)
- Remove scale from captcha modal animation (globals.css)
- Add .gpu-accelerated utility class for future use

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-22 13:28:47 +08:00
sagit 4ad3aa2c06 feat: redesign forward card view completely syncing filters and sorti… (#179)
…ng logic
2026-02-21 21:19:53 +08:00
sagitchu 357a4b165e Merge remote-tracking branch 'origin/main' into card-view 2026-02-21 21:18:20 +08:00
sagitchu a15be253f5 feat: add framer-motion animations (page transitions, modals, dropdowns, buttons, search bar) 2026-02-21 21:16:42 +08:00
sagitchu 572d1c16a6 feat: Implement toggleable search input and data filtering across tunnel, user, and forward pages. 2026-02-21 20:43:32 +08:00
sagit 39e22c07de feat(backend): auto redeploy tunnel and forward config after node upgrade (#180) 2026-02-21 12:29:34 +00:00
sagitchu ca24573803 feat: redesign forward card view completely syncing filters and sorting logic 2026-02-21 20:08:29 +08:00
sagit 0cb3263a2e feat(frontend): support markdown in dashboard announcements (#176)
## Summary
- render dashboard announcements with Markdown using `react-markdown` +
`remark-gfm` while sanitizing output with `rehype-sanitize`
- add styled Markdown element mappings in the announcement banner for
links, lists, code blocks, and blockquotes
- update announcement management hint text to communicate Markdown
support in the config page

## Verification
- npm run build (vite-frontend)
2026-02-21 16:50:19 +08:00
sagit fb2189c924 Merge branch 'main' into feat/announcement-markdown-support 2026-02-21 16:36:51 +08:00
sagitchu 1383174b31 refactor: Standardize empty state UI for tunnel and user pages, update announcement banner styling, and add new entries to .gitignore. 2026-02-21 16:36:36 +08:00
sagitchu c95bde7055 style: space out provider and consumer share panel title cards from tabs 2026-02-21 16:36:05 +08:00
Sagit 57f5e3a1a3 feat(frontend): support markdown in dashboard announcements 2026-02-21 05:48:24 +00:00
sagit 66ad52c199 feat(node): add stable/test release channels for install and upgrade (#175)
## Summary
- classify release tags by channel: pure numeric tags are treated as
stable, while tags containing alpha/beta/rc (or other non-numeric
formats) are treated as test releases
- add channel-aware backend APIs for install/upgrade/release listing and
pin install commands to the selected tag via `VERSION=<tag>`
- update node page UI and API clients to let users pick stable vs test
channel for install and upgrade workflows

## Verification
- `go test ./internal/http/handler/...`
- `npm run build`
2026-02-21 13:23:20 +08:00
Sagit 2081dc9658 feat(node): support stable and test release channels 2026-02-21 04:17:20 +00:00
sagit b93255df3d feat: improve forward page grouped view (#165)
Added drag-and-drop sorting and filtering to grouped view.
2026-02-20 19:52:26 +08:00
sagitchu e1aef8700e style: make filter button icon-only 2026-02-20 19:49:57 +08:00
sagitchu d5b3a39774 feat: replace inline filters with modal dialog 2026-02-20 19:45:48 +08:00
sagitchu 022e9e3807 style: tightly pack user and tunnel filters 2026-02-20 19:39:12 +08:00
sagitchu d333d463f6 style: fix dropdown filter spacing 2026-02-20 19:33:56 +08:00
sagitchu abc9f21ab9 feat: improve forward page grouped view 2026-02-20 19:27:40 +08:00
sagit d216567c02 fix(frontend): switch grouped forward view to full list (#161)
* fix(frontend): left-align announcement logo in homepage banner

* fix(frontend): keep multiselect panels floating and preserve summaries

* fix(frontend): rebalance config card header spacing

* fix(frontend): normalize formatting and fix multiselect modal behavior

* fix(frontend): polish batch actions and sharing page guidance

* fix(frontend): switch grouped forward view to full list

* fix(backend): switch diagnosis internet target to bing

* style(frontend): beautify forward group view list display

* style: remove 'x' suffix from traffic ratio input and center config alert

* fix(ui): expand exit node select upwards in modal

* style: fix vertical alignment of logo and title in announcement banner
2026-02-20 10:31:39 +00:00
sagit 45bfd35a20 fix(backend): respect tunnel IP preference in forward diagnosis (#160)
Forward diagnosis chain-hop probes now inherit the tunnel ipPreference so v6-priority tunnels test IPv6 targets instead of defaulting to IPv4. Add a contract test to lock IPv6 target selection for entry->chain and chain->exit diagnostics.
2026-02-20 05:56:12 +00:00
sagit 5a1b72387d fix(frontend): polish batch actions and sharing page guidance (#157)
* fix(frontend): polish batch actions and sharing page guidance

* fix(frontend): add top spacing above sharing section banners

* fix(frontend): allow manual and calendar expiry date input

* fix(frontend): switch batch action buttons to warning tone

* fix(frontend): switch batch action buttons to default tone
2026-02-20 12:50:17 +08:00
sagit 66de566a00 fix(frontend): stabilize multiselect behavior and UI spacing (#154)
* fix(frontend): left-align announcement logo in homepage banner

* fix(frontend): keep multiselect panels floating and preserve summaries

* fix(frontend): rebalance config card header spacing

* fix(frontend): normalize formatting and fix multiselect modal behavior

* fix(frontend): restore config divider spacing on desktop
2026-02-19 20:50:12 +08:00
sagit 18c2da7c7e fix(frontend): prevent multiselect overflow and normalize card spacing (#152)
## Summary
- fix multi-select trigger overflow in shared select bridge by making
trigger/value flex children shrink correctly
- harden grouped assignment summaries against long selected-value text
wrapping overflow
- normalize card header/body spacing and node card IP row height so card
layouts stay visually consistent across modules

## Verification
- ran `npm run build` in `vite-frontend` successfully
- checked diagnostics on changed frontend files (no diagnostics)
2026-02-19 18:23:28 +08:00
Sagit c4d807f1c4 fix(frontend): align card body spacing with node cards 2026-02-19 10:16:59 +00:00
Sagit d1460ab9c7 fix(frontend): tighten remaining card header spacing 2026-02-19 09:52:14 +00:00
Sagit 9189c68800 fix(frontend): normalize card spacing and multiselect overflow 2026-02-19 09:17:04 +00:00
sagit efbdabceca fix(frontend): align diagnosis status and permission layout (#148)
* fix(frontend): align diagnosis status and permission layout

* fix(frontend): polish batch toolbar controls on cards

* fix(user): stabilize tunnel permission checkbox interactions
2026-02-19 16:46:37 +08:00
112 changed files with 9524 additions and 2020 deletions
+3
View File
@@ -268,3 +268,6 @@ sql/
!go-backend/internal/store/postgres/sql/
!go-backend/internal/store/postgres/sql/schema.sql
!go-backend/internal/store/postgres/sql/data.sql
go-backend/gost.db-shm
.gitignore
go-backend/gost.db-wal
+29 -11
View File
@@ -1,12 +1,12 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Thu Feb 19 2026
**Commit:** 137c34e
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Branch:** main
**Tag:** 2.1.4-rc2
**Tag:** 2.1.5-rc15
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
@@ -14,12 +14,14 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
│ └── tests/contract/ # Integration/contract tests
├── vite-frontend/ # React/Vite dashboard (shadcn bridge + Tailwind v4)
│ └── src/shadcn-bridge/heroui/ # HeroUI-compatible facade
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
├── install.sh # Node installer/upgrader (downloads gost binary)
└── .github/workflows/ # CI: build/push images + release artifacts
└── .github/workflows/ # CI: build/test + Docker push + release artifacts
```
## WHERE TO LOOK
@@ -29,12 +31,15 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
| **Admin API** | `go-backend/` | Go Admin API (SQLite/PostgreSQL) |
| **Web UI** | `vite-frontend/` | React/Vite dashboard (shadcn bridge + Tailwind v4) |
| **UI Compatibility** | `vite-frontend/src/shadcn-bridge/heroui/` | HeroUI-compatible API wrappers backed by shadcn/radix |
| **Theme Tokens** | `vite-frontend/src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic color mapping |
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
| **Repository Layer** | `go-backend/internal/store/repo/` | GORM data access (repository.go 83k LOC) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
| **CI Workflows** | `.github/workflows/` | ci-build.yml, docker-build.yml, deploy-docs.yml |
## CODE MAP
| Symbol | Type | Location | Role |
@@ -43,7 +48,9 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
| `main` | Func | `go-gost/main.go` | Agent Entry |
| `Repository` | Struct | `go-backend/internal/store/repo/repository.go` | Data Access Layer |
| `Handler` | Struct | `go-backend/internal/http/handler/handler.go` | HTTP Handlers |
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
## CONVENTIONS
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
@@ -52,6 +59,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
## ANTI-PATTERNS (THIS PROJECT)
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
@@ -60,6 +68,8 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
## COMMANDS
```bash
@@ -75,15 +85,21 @@ docker compose -f docker-compose-v6.yml up -d
(cd go-backend && make build)
(cd vite-frontend && npm run dev)
(cd go-gost && go run .)
# Testing
(cd go-backend && go test ./...)
(cd go-backend && go test ./tests/contract/...)
```
## UNIQUE STYLES
- **Flat Monorepo**: Language-prefixed dirs (`go-backend`, `go-gost`, `vite-frontend`) instead of `apps/`/`libs/`.
- **Asymmetric Go Layout**: `go-backend` follows `cmd/<app>/main.go` while `go-gost` uses `root/main.go`.
- **Frontend Hybrid Mode**: `App.tsx` detects "H5 mode" (mobile WebView) vs desktop, dictating layout strategy.
- **Experimental Bundler**: `vite-frontend` uses `rolldown-vite` (Rust-based) instead of standard Vite.
- **Non-minified Builds**: `vite.config.ts` sets `minify: false`, `treeshake: false` for debugging.
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- LSP servers are not installed in this environment (gopls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
@@ -91,7 +107,9 @@ docker compose -f docker-compose-v6.yml up -d
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure (Vitest/Jest not configured).
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged into `main`; release tag `2.1.4-rc2` points to commit `137c34e`.
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
+148
View File
@@ -0,0 +1,148 @@
# 限速功能重构实施计划
## 一、需求概述
**原始需求**: 限速功能当前绑定到具体隧道,需要改为不绑定隧道,创建限速后可以自由在隧道上限速,也可以在转发上限速。
**核心变更**:
1. 限速规则(SpeedLimit)与隧道的绑定关系改为可选
2. 转发(Forward)支持独立的限速规则
---
## 二、实施计划清单
### 2.0 计划状态(审计更新:2026-02-26)
- 总体状态:**进行中(未验收通过)**
- 已完成:模型、仓储查询、限速 CRUD、控制面优先级、限速页与类型改造、编译与测试通过
- 未完成:**Forward 独立限速写入链路**(前端表单 -> API handler -> repository 落库 `forward.speed_id`)
### 2.1 后端模型层 (Model)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| M1 | SpeedLimit.TunnelID 改为 sql.NullInt64 (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M2 | SpeedLimit.TunnelName 改为 sql.NullString (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M3 | Forward 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M4 | ForwardRecord 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M5 | SpeedLimitBackup.TunnelID 改为指针类型 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
| M6 | ForwardBackup 添加 SpeedID *int64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 |
### 2.2 后端仓储层 (Repository)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| R1 | ListSpeedLimits() 返回可空 tunnelId/tunnelName | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R2 | ListForwards() 返回 speedId 字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R3 | CreateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R4 | UpdateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R5 | GetSpeedLimitTunnelID() 返回 sql.NullInt64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| R6 | exportSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R7 | importSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 |
| R8 | GetSpeedLimitSpeed() 新增方法 | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R9 | ListForwardsByTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_control.go` | ✅ 完成 |
| R10 | ListActiveForwardsByUser() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R11 | ListActiveForwardsByUserTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
| R12 | GetForwardRecord() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 |
### 2.3 后端处理器层 (Handler)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| H1 | speedLimitCreate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| H2 | speedLimitUpdate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| H3 | speedLimitDelete 处理可空 tunnelID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
### 2.4 后端控制平面 (Control Plane)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| C1 | syncForwardServices 优先使用 Forward.SpeedID | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
| C2 | 回退到 UserTunnel 的 speed limit | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 |
### 2.5 前端类型定义 (TypeScript Types)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| T1 | SpeedLimitApiItem.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T2 | ForwardApiItem 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T3 | ForwardMutationPayload 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
| T4 | SpeedLimitMutationPayload.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 |
### 2.6 前端页面组件
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| F1 | SpeedLimitRule 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F2 | SpeedLimitForm 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F3 | validateForm 移除 tunnelId 必填校验 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F4 | Select 组件改为可选 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
| F5 | 显示"未绑定"状态 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 |
### 2.7 编译验证
| 序号 | 任务 | 状态 |
|------|------|------|
| B1 | Go 后端编译通过 | ✅ 完成 |
| B2 | TypeScript 类型检查通过 | ✅ 完成 |
| B3 | `go test ./...` 全量通过 | ✅ 完成 |
| B4 | `go test ./tests/contract/... -run SpeedLimit` 通过 | ✅ 完成 |
### 2.8 Forward 独立限速写入链路补全(新增)
| 序号 | 任务 | 文件 | 状态 |
|------|------|------|------|
| N1 | forwardCreate 支持接收并校验可选 speedId,写入 Forward.SpeedID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| N2 | forwardUpdate 支持更新/清空 speedId,并触发服务重下发 | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 |
| N3 | CreateForwardTx 支持落库 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| N4 | UpdateForward 支持更新 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 |
| N5 | Forward 页面新增限速选择并透传 speedId | `vite-frontend/src/pages/forward.tsx` | ✅ 完成 |
| N6 | Forward 相关契约测试补充 speedId 写入/清空断言 | `go-backend/tests/contract/forward_contract_test.go` | ✅ 完成 |
---
## 三、优先级说明
限速规则应用优先级:
1. **Forward.SpeedID** - 转发级别的限速 (最高优先)
2. **UserTunnel.SpeedID** - 用户隧道权限级别的限速 (回退)
---
## 四、数据库兼容性
- SpeedLimit 表: `tunnel_id` 和 `tunnel_name` 字段改为可空 (GORM AutoMigrate 自动处理)
- Forward 表: 新增 `speed_id` 可空字段 (GORM AutoMigrate 自动处理)
---
## 五、验证检查项
### 5.1 功能验证(审计后)
- [x] 创建不限速规则的限速 (不绑定隧道)
- [x] 创建绑定隧道的限速 (兼容旧逻辑)
- [x] 编辑限速规则,切换隧道绑定状态
- [ ] 删除限速规则
- [ ] 转发列表正确显示 speedId
### 5.2 API 验证(审计后)
- [x] GET /api/speed-limit/list 返回可选 tunnelId
- [x] POST /api/speed-limit/create 接受可选 tunnelId
- [x] POST /api/speed-limit/update 接受可选 tunnelId
- [ ] GET /api/forward/list 返回 speedId
### 5.3 兼容性验证(审计后)
- [x] 现有绑定隧道的限速规则继续正常工作
- [ ] 现有 UserTunnel 的限速继续正常工作
- [ ] 备份/恢复功能正常
### 5.4 Forward 独立限速闭环验证(新增)
- [x] POST /api/forward/create 接受 speedId 并写入 `forward.speed_id`
- [x] POST /api/forward/update 可更新/清空 speedId
- [x] Forward 表单可选择限速并提交 speedId
- [ ] `syncForwardServices` 实际使用 Forward.SpeedID 而非仅回退 UserTunnel.SpeedID
+12 -8
View File
@@ -2,7 +2,7 @@
## OVERVIEW
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
**Stack:** Go 1.23, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
## STRUCTURE
```
@@ -17,14 +17,15 @@ go-backend/
│ ├── store/
│ │ ├── model/model.go # GORM model structs (single source of truth)
│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD)
│ │ ├── repository_federation.go# Federation-specific queries
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
│ │ ├── repository_federation.go # Federation-specific queries
│ │ ├── repository_flow.go # Flow/forward status queries
│ │ └── repository_control.go # Control plane queries
│ │ ├── repository_control.go # Control plane queries
│ │ └── repository_groups.go # Group management queries
│ └── auth/ # Auth logic
├── tests/ # Integration/Contract tests
├── Dockerfile # Multi-stage build (alpine)
├── tests/contract/ # Integration/contract tests (14 tests)
├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)
└── Makefile # Build commands
```
@@ -36,6 +37,7 @@ go-backend/
| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
## CONVENTIONS
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
@@ -47,6 +49,7 @@ go-backend/
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
## ANTI-PATTERNS
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
@@ -58,6 +61,7 @@ go-backend/
```bash
cd go-backend
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
go test ./...
go test ./... # Unit tests
go test ./tests/contract/... # Contract tests
make build
```
+5 -4
View File
@@ -1,10 +1,10 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Thu Feb 26 2026
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
**Stack:** Go 1.23, net/http, GORM via Repository pattern.
**Stack:** Go 1.24, net/http, GORM via Repository pattern.
## STRUCTURE
```
@@ -14,7 +14,7 @@ handler/
├── federation.go # Federation/cluster sync API
├── flow_policy.go # Traffic policy API
├── jobs.go # Background job management (sync, cleanup)
├── mutations.go # CRUD for users, tunnels, forwards (largest: 100k+ LOC)
├── mutations.go # CRUD for users, tunnels, forwards (~3700 LOC)
└── upgrade.go # System upgrade API
```
@@ -26,10 +26,11 @@ handler/
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
| **Node Control** | `control_plane.go` | Node add/delete/list operations |
## CONVENTIONS
- Inherits from parent: GORM via Repository pattern, JWT in Authorization header.
- Large files expected (`mutations.go` 3716 LOC - central mutation hub).
- Large files expected (`mutations.go` ~3700 LOC - central mutation hub).
- Uses `repo.Repository` for DB access via `h.repo.XXX()` methods.
- Handlers never call `repo.DB()` directly — all queries go through Repository methods.
- Domain-driven file split: one file per functional area (federation, jobs, etc.).
@@ -152,11 +152,32 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
return errors.New("转发入口端口不存在")
}
userTunnelID, limiterID, speed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
// Determine limiter from forward's SpeedID first, fallback to UserTunnel's limiter
var limiterID *int64
var speed *int
if forward.SpeedID.Valid && forward.SpeedID.Int64 > 0 {
// Forward has its own speed limit
speedVal, err := h.repo.GetSpeedLimitSpeed(forward.SpeedID.Int64)
if err == nil && speedVal > 0 {
limiterID = &forward.SpeedID.Int64
speed = &speedVal
}
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
if limiterID == nil {
// Fall back to UserTunnel speed limit
var utLimiterID *int64
var utSpeed *int
_, utLimiterID, utSpeed, err = h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
limiterID = utLimiterID
speed = utSpeed
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, 0)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return err
@@ -164,7 +185,9 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
for _, fp := range ports {
if limiterID != nil && speed != nil {
h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed)
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
return err
}
}
node, err := h.getNodeRecord(fp.NodeID)
@@ -288,7 +311,7 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if tolerateExists {
if strings.Contains(msg, "exists") || strings.Contains(msg, "already") || strings.Contains(msg, "已存在") {
if isAlreadyExistsMessage(msg) {
return result, nil
}
}
@@ -360,6 +383,8 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
return nil, errors.New("隧道配置不完整")
}
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
results := make([]map[string]interface{}, 0, len(chainRows)*2+len(targets))
nodeCache := map[int64]*nodeRecord{}
@@ -383,7 +408,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
"fromChainType": 1,
"toChainType": 2,
"toInx": firstNode.Inx,
}, "")
}, ipPreference)
}
} else {
for _, outNode := range outNodes {
@@ -391,7 +416,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
h.appendChainHopDiagnosis(&results, nodeCache, inNode.NodeID, outNode, description, map[string]interface{}{
"fromChainType": 1,
"toChainType": 3,
}, "")
}, ipPreference)
}
}
}
@@ -406,7 +431,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
"fromInx": currentNode.Inx,
"toChainType": 2,
"toInx": nextNode.Inx,
}, "")
}, ipPreference)
}
} else {
for _, outNode := range outNodes {
@@ -415,7 +440,7 @@ func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]int
"fromChainType": 2,
"fromInx": currentNode.Inx,
"toChainType": 3,
}, "")
}, ipPreference)
}
}
}
@@ -479,7 +504,7 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
case 1:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 1,
})
}
@@ -532,14 +557,14 @@ func (h *Handler) diagnoseTunnelRuntime(tunnelID int64) (map[string]interface{},
for _, outNode := range outNodes {
description := fmt.Sprintf("出口(%s)->外网", outNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
h.appendPathDiagnosis(&results, nodeCache, outNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 3,
})
}
default:
for _, inNode := range inNodes {
description := fmt.Sprintf("入口(%s)->外网", inNode.NodeName)
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.google.com", 443, description, map[string]interface{}{
h.appendPathDiagnosis(&results, nodeCache, inNode.NodeID, "www.bing.com", 443, description, map[string]interface{}{
"fromChainType": 1,
})
}
@@ -882,6 +907,17 @@ func isNotFoundError(err error) bool {
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
}
func isAlreadyExistsMessage(message string) bool {
msg := strings.ToLower(strings.TrimSpace(message))
if msg == "" {
return false
}
if strings.Contains(msg, "address already in use") {
return false
}
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
@@ -1028,12 +1064,16 @@ func (h *Handler) sendDeleteLimiterConfig(limiterID int64, tunnelID int64) error
return nil
}
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) {
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {
rate := float64(speed) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
payload := map[string]interface{}{
"name": strconv.FormatInt(limiterID, 10),
"limits": []string{limitStr},
}
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
if _, err := h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false); err != nil {
return fmt.Errorf("限速规则下发失败: %w", err)
}
return nil
}
@@ -53,3 +53,15 @@ func TestShouldTryLegacySingleService(t *testing.T) {
t.Fatalf("DeleteService should not require legacy fallback")
}
}
func TestIsAlreadyExistsMessage(t *testing.T) {
if !isAlreadyExistsMessage("service demo already exists") {
t.Fatalf("expected already exists message to be tolerated")
}
if !isAlreadyExistsMessage("服务已存在") {
t.Fatalf("expected Chinese already exists message to be tolerated")
}
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
t.Fatalf("address already in use must not be treated as already exists")
}
}
+274 -28
View File
@@ -477,9 +477,14 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
forwardPortRows, err := h.repo.ListActiveForwardPortsForNode(nodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
usedSet := make(map[int]struct{})
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows))
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows)+len(forwardPortRows))
for _, b := range bindingRows {
bindings = append(bindings, remoteUsageBindingItem{
BindingID: b.ID,
@@ -496,6 +501,29 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
usedSet[b.AllocatedPort] = struct{}{}
}
}
for _, fp := range forwardPortRows {
bindings = append(bindings, remoteUsageBindingItem{
BindingID: -fp.ForwardID,
TunnelID: fp.TunnelID,
TunnelName: fp.TunnelName,
ChainType: 1,
HopInx: 0,
AllocatedPort: fp.Port,
ResourceKey: fmt.Sprintf("forward:%d", fp.ForwardID),
RemoteBindingID: "",
UpdatedTime: fp.UpdatedTime,
})
if fp.Port > 0 {
usedSet[fp.Port] = struct{}{}
}
}
sort.Slice(bindings, func(i, j int) bool {
if bindings[i].AllocatedPort == bindings[j].AllocatedPort {
return bindings[i].BindingID < bindings[j].BindingID
}
return bindings[i].AllocatedPort < bindings[j].AllocatedPort
})
usedPorts := make([]int, 0, len(usedSet))
for port := range usedSet {
@@ -766,6 +794,37 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, port := range usedPorts {
if port == req.RemotePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
}
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if runtimeOnPort != nil {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if existsOnNodePort {
response.WriteJSON(w, response.Err(403, "Port already in use"))
return
}
now := time.Now().UnixMilli()
tunnelID, err := h.repo.CreateFederationTunnel(
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
@@ -780,6 +839,30 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
return
}
runtime := &repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
BindingID: "",
Role: "forward",
ChainName: "",
ServiceName: "",
Protocol: defaultString(req.Protocol, "tcp"),
Strategy: "fifo",
Port: req.RemotePort,
Target: strings.TrimSpace(req.Target),
Applied: 0,
Status: 1,
CreatedTime: now,
UpdatedTime: now,
}
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
_ = h.deleteTunnelByID(tunnelID)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
response.WriteJSON(w, response.OK(map[string]interface{}{
@@ -1211,9 +1294,182 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
h.bindPeerShareForwardRuntimeServices(share, req.Data)
} else if strings.EqualFold(cmd, "deleteservice") {
h.releasePeerShareForwardRuntimeServices(share, req.Data)
}
response.WriteJSON(w, response.OK(res))
}
type federationForwardServiceBinding struct {
Name string
Port int
}
func extractFederationServiceEntries(data interface{}) []map[string]interface{} {
if data == nil {
return nil
}
if entries := asMapSlice(data); len(entries) > 0 {
return entries
}
dataMap, ok := data.(map[string]interface{})
if !ok {
return nil
}
if entries := asMapSlice(dataMap["services"]); len(entries) > 0 {
return entries
}
return nil
}
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
serviceList := extractFederationServiceEntries(data)
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
for _, svcMap := range serviceList {
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
if name == "" {
continue
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
continue
}
addr := strings.TrimSpace(asString(svcMap["addr"]))
if addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
continue
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
continue
}
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
}
return bindings
}
func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
names := make(map[string]struct{})
appendName := func(raw string) {
name := normalizeForwardRuntimeServiceName(raw)
if name == "" {
return
}
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
return
}
names[name] = struct{}{}
}
for _, svcMap := range extractFederationServiceEntries(data) {
appendName(asString(svcMap["name"]))
}
if dataMap, ok := data.(map[string]interface{}); ok {
for _, item := range asAnySlice(dataMap["services"]) {
appendName(asString(item))
}
}
for _, item := range asAnySlice(data) {
appendName(asString(item))
}
if len(names) == 0 {
return nil
}
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
sort.Strings(out)
return out
}
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return
}
bindings := parseFederationForwardServiceBindings(data)
if len(bindings) == 0 {
return
}
now := time.Now().UnixMilli()
for _, binding := range bindings {
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
if err != nil {
continue
}
if runtime == nil {
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
if err != nil {
continue
}
}
if runtime == nil {
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
ShareID: share.ID,
NodeID: share.NodeID,
ReservationID: randomToken(24),
ResourceKey: fmt.Sprintf("forward-runtime:%d:%s:%d:%s", share.ID, binding.Name, binding.Port, randomToken(8)),
BindingID: "",
Role: "forward",
ChainName: "",
ServiceName: binding.Name,
Protocol: "tcp",
Strategy: "fifo",
Port: binding.Port,
Target: "",
Applied: 1,
Status: 1,
CreatedTime: now,
UpdatedTime: now,
})
continue
}
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
continue
}
runtime.ServiceName = binding.Name
runtime.Port = binding.Port
runtime.Applied = 1
runtime.Status = 1
runtime.UpdatedTime = now
if strings.TrimSpace(runtime.Protocol) == "" {
runtime.Protocol = "tcp"
}
if strings.TrimSpace(runtime.Strategy) == "" {
runtime.Strategy = "fifo"
}
_ = h.repo.UpdatePeerShareRuntime(runtime)
}
}
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
if h == nil || h.repo == nil || share == nil {
return
}
names := parseFederationForwardServiceNamesForRelease(data)
if len(names) == 0 {
return
}
now := time.Now().UnixMilli()
for _, name := range names {
_ = h.repo.MarkForwardPeerShareRuntimeReleasedByServiceName(share.ID, name, now)
}
}
func isFederationRuntimeCommandAllowed(commandType string) bool {
switch strings.ToLower(strings.TrimSpace(commandType)) {
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
@@ -1236,36 +1492,26 @@ func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) err
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
return nil
}
dataMap, ok := data.(map[string]interface{})
if !ok {
serviceList := extractFederationServiceEntries(data)
if len(serviceList) == 0 {
return nil
}
if services, ok := dataMap["services"]; ok {
serviceList, ok := services.([]interface{})
if !ok {
return fmt.Errorf("invalid services format")
for _, svcMap := range serviceList {
addr := asString(svcMap["addr"])
if addr == "" {
continue
}
for _, svc := range serviceList {
svcMap, ok := svc.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid service entry format")
}
addr, ok := svcMap["addr"].(string)
if !ok || addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("invalid service address: %s", addr)
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 {
return fmt.Errorf("invalid port in service address: %s", addr)
}
if port < share.PortRangeStart || port > share.PortRangeEnd {
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
}
}
@@ -414,6 +414,445 @@ func TestFederationShareResetFlow(t *testing.T) {
}
}
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-forward-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-forward-share",
NodeID: nodeID,
Token: "federation-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24000,
PortRangeEnd: 24020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("federation-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
body, err := json.Marshal(federationTunnelRequest{
Protocol: "tcp",
RemotePort: 24001,
Target: "1.1.1.1:443",
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+share.Token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
if runtimeCount != 1 {
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
}
}
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "federation-port-check-share",
NodeID: nodeID,
Token: "federation-port-check-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 24100,
PortRangeEnd: 24120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
create := func() response.R {
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer federation-port-check-token")
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
h.federationTunnelCreate(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
return payload
}
first := create()
if first.Code != 0 {
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
}
second := create()
if second.Code != 403 {
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
}
if second.Msg != "Port already in use" {
t.Fatalf("expected occupied port message, got %q", second.Msg)
}
}
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "delete-forward-share",
NodeID: 1,
Token: "delete-forward-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 25000,
PortRangeEnd: 25020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("delete-forward-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
if err := h.deleteTunnelByID(1); err != nil {
t.Fatalf("delete tunnel: %v", err)
}
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
if activeCount != 0 {
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
}
}
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-forward-role-share",
NodeID: 1,
Token: "bind-forward-role-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26000,
PortRangeEnd: 26020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-forward-role-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtimes: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
},
})
forwardServiceName := ""
middleServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
t.Fatalf("load middle runtime service name: %v", err)
}
if forwardServiceName != "77_2_10" {
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
}
if middleServiceName != "" {
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
}
}
func TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-array-share",
NodeID: 1,
Token: "bind-array-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26100,
PortRangeEnd: 26120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-array-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
1, share.ID, share.NodeID, "bind-array-r1", "bind-array-rk1", "", "forward", "", "", "tcp", "fifo", 26101, "", 0, 1, now, now,
).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, []interface{}{
map[string]interface{}{"name": "99_2_10_tcp", "addr": "[::]:26101"},
map[string]interface{}{"name": "99_2_10_udp", "addr": "[::]:26101"},
})
forwardServiceName := ""
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
t.Fatalf("load forward runtime service name: %v", err)
}
if forwardServiceName != "99_2_10" {
t.Fatalf("expected forward runtime service name bound from top-level array, got %q", forwardServiceName)
}
}
func TestBindPeerShareForwardRuntimeServicesCreatesRuntimeWhenMissing(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-bind-create-runtime.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "bind-create-runtime-share",
NodeID: 1,
Token: "bind-create-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26300,
PortRangeEnd: 26320,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("bind-create-runtime-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{
map[string]interface{}{"name": "55_2_10_tcp", "addr": "[::]:26301"},
},
})
var count int64
if err := r.DB().Raw(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND role = ? AND status = 1`, share.ID, "forward").Scan(&count).Error; err != nil {
t.Fatalf("query runtime count: %v", err)
}
if count != 1 {
t.Fatalf("expected 1 active forward runtime row, got %d", count)
}
var serviceName string
var port int
var applied int
if err := r.DB().Raw(`SELECT service_name, port, applied FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&serviceName, &port, &applied); err != nil {
t.Fatalf("query created runtime: %v", err)
}
if serviceName != "55_2_10" {
t.Fatalf("expected service_name=55_2_10, got %q", serviceName)
}
if port != 26301 {
t.Fatalf("expected port=26301, got %d", port)
}
if applied != 1 {
t.Fatalf("expected applied=1, got %d", applied)
}
}
func TestReleasePeerShareForwardRuntimeServicesMarksRuntimeReleased(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-release-runtime.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "release-runtime-share",
NodeID: 1,
Token: "release-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 26400,
PortRangeEnd: 26420,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken("release-runtime-token")
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "release-r1", "release-rk1", "", "forward", "", "77_2_10", "tcp", "fifo", 26401, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert runtime: %v", err)
}
h.releasePeerShareForwardRuntimeServices(share, map[string]interface{}{
"services": []interface{}{"77_2_10_tcp"},
})
var status int
var applied int
var serviceName string
if err := r.DB().Raw(`SELECT status, applied, service_name FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&status, &applied, &serviceName); err != nil {
t.Fatalf("query released runtime: %v", err)
}
if status != 0 {
t.Fatalf("expected status=0 after release, got %d", status)
}
if applied != 0 {
t.Fatalf("expected applied=0 after release, got %d", applied)
}
if serviceName != "" {
t.Fatalf("expected service_name cleared after release, got %q", serviceName)
}
}
func TestValidateFederationCommandPortsAcceptsTopLevelServiceArray(t *testing.T) {
share := &repo.PeerShare{
PortRangeStart: 26200,
PortRangeEnd: 26210,
}
err := validateFederationCommandPorts(share, []interface{}{
map[string]interface{}{"name": "11_2_10_tcp", "addr": "[::]:26201"},
map[string]interface{}{"name": "11_2_10_udp", "addr": "[::]:26201"},
})
if err != nil {
t.Fatalf("expected top-level service array to pass port validation, got: %v", err)
}
}
func TestFederationRemoteUsageList(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
@@ -502,6 +941,110 @@ func TestFederationRemoteUsageList(t *testing.T) {
}
}
func TestFederationRemoteUsageListIncludesForwardPorts(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-usage.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
h := New(r, "test-jwt-secret")
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-usage-remote-node", "forward-usage-secret", "10.60.70.80", "10.60.70.80", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", `{"shareId":99,"maxBandwidth":0,"currentFlow":0,"portRangeStart":33000,"portRangeEnd":33010}`).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
var nodeID int64
if err := r.DB().Raw(`SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-remote-node").Row().Scan(&nodeID); err != nil {
t.Fatalf("query node id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-usage-tunnel", 1, "tls", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
var tunnelID int64
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-tunnel").Row().Scan(&tunnelID); err != nil {
t.Fatalf("query tunnel id: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "tester", "forward-usage-item", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
var forwardID int64
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-item").Row().Scan(&forwardID); err != nil {
t.Fatalf("query forward id: %v", err)
}
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 33001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
res := httptest.NewRecorder()
h.federationRemoteUsageList(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
}
var payload response.R
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Code != 0 {
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
}
rows, ok := payload.Data.([]interface{})
if !ok || len(rows) == 0 {
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
}
first, ok := rows[0].(map[string]interface{})
if !ok {
t.Fatalf("expected usage row map, got %T", rows[0])
}
usedPortsRaw, ok := first["usedPorts"].([]interface{})
if !ok {
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
}
if len(usedPortsRaw) != 1 || int(usedPortsRaw[0].(float64)) != 33001 {
t.Fatalf("expected usedPorts [33001], got %v", usedPortsRaw)
}
bindingsRaw, ok := first["bindings"].([]interface{})
if !ok {
t.Fatalf("expected bindings array, got %T", first["bindings"])
}
if len(bindingsRaw) != 1 {
t.Fatalf("expected 1 binding row from forward usage, got %d", len(bindingsRaw))
}
binding, ok := bindingsRaw[0].(map[string]interface{})
if !ok {
t.Fatalf("expected binding row object, got %T", bindingsRaw[0])
}
if int(binding["allocatedPort"].(float64)) != 33001 {
t.Fatalf("expected allocatedPort=33001, got %v", binding["allocatedPort"])
}
if int(binding["chainType"].(float64)) != 1 {
t.Fatalf("expected chainType=1 for forward usage row, got %v", binding["chainType"])
}
}
func TestAuthPeerAllowedIPs(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
if err != nil {
+193 -1
View File
@@ -2,9 +2,12 @@ package handler
import (
"encoding/json"
"log"
"strconv"
"strings"
"time"
"go-backend/internal/store/model"
)
const bytesPerGB int64 = 1024 * 1024 * 1024
@@ -30,7 +33,7 @@ type namedConfigItem struct {
Name string `json:"name"`
}
func (h *Handler) processFlowItem(item flowItem) {
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
serviceName := strings.TrimSpace(item.N)
if serviceName == "" || serviceName == "web_api" {
return
@@ -40,6 +43,7 @@ func (h *Handler) processFlowItem(item flowItem) {
if ok {
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
@@ -87,6 +91,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
return runtimeID, true
}
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, 0, false
}
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
port, err := strconv.Atoi(portValue)
if err != nil || port <= 0 {
return 0, 0, false
}
return shareID, port, true
}
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
tunnelName = strings.TrimSpace(tunnelName)
if !strings.HasPrefix(tunnelName, "Share-") {
return 0, false
}
raw := strings.TrimPrefix(tunnelName, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0, false
}
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
if err != nil || shareID <= 0 {
return 0, false
}
return shareID, true
}
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
if h == nil || h.repo == nil || runtimeID <= 0 {
return
@@ -113,6 +156,121 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
h.enforcePeerShareFlowLimit(share.ID)
}
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || forwardID <= 0 {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
forward, err := h.getForwardRecord(forwardID)
if err != nil || forward == nil {
// Forward not found in local database - might be a federation port-forward
// Try to find by service name in peer_share_runtime
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
if err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
if !ok {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
return
}
share, err := h.repo.GetPeerShare(shareID)
if err != nil || share == nil {
return
}
if !isPeerShareFlowExceeded(share) {
return
}
h.enforcePeerShareFlowLimit(share.ID)
}
func normalizeForwardRuntimeServiceName(serviceName string) string {
name := strings.TrimSpace(serviceName)
if strings.HasSuffix(name, "_tcp") {
return strings.TrimSuffix(name, "_tcp")
}
if strings.HasSuffix(name, "_udp") {
return strings.TrimSuffix(name, "_udp")
}
return name
}
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
return
}
delta := item.D + item.U
if delta <= 0 {
return
}
normalized := normalizeForwardRuntimeServiceName(serviceName)
var runtimes []model.PeerShareRuntime
var err error
// Try node-scoped query first if nodeID is valid
if nodeID > 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
if err != nil {
return
}
}
}
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
if len(runtimes) == 0 {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
if err != nil {
return
}
if len(runtimes) == 0 && normalized != serviceName {
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
if err != nil {
return
}
}
}
if len(runtimes) != 1 {
if len(runtimes) > 1 {
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
}
return
}
runtime := runtimes[0]
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
if err != nil || matchedShare == nil {
return
}
if isPeerShareFlowExceeded(matchedShare) {
h.enforcePeerShareFlowLimit(matchedShare.ID)
}
}
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
@@ -271,15 +429,46 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
}
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
if err != nil {
return
}
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
if err != nil {
hasUnboundForwardPeerRuntime = false
}
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
for _, serviceName := range runtimeServiceNames {
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
continue
}
runtimeServiceSet[serviceName] = struct{}{}
}
for _, item := range services {
name := strings.TrimSpace(item.Name)
if name == "" || name == "web_api" {
continue
}
if strings.HasPrefix(name, "fed_svc_") {
continue
}
normalizedName := normalizeForwardRuntimeServiceName(name)
if _, ok := runtimeServiceSet[normalizedName]; ok {
continue
}
if _, ok := runtimeServiceSet[name]; ok {
continue
}
parts := strings.Split(name, "_")
if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
continue
@@ -299,6 +488,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
continue
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
continue
}
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
continue
}
@@ -2,6 +2,7 @@ package handler
import (
"path/filepath"
"strconv"
"testing"
"time"
@@ -43,7 +44,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
}
h := &Handler{repo: r}
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
@@ -61,3 +62,345 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
}
}
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-share",
NodeID: 1,
Token: "forward-share-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 30000,
PortRangeEnd: 30010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-share-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
`, now+24*60*60*1000, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, tunnelName, now, now).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
`, now+24*60*60*1000).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "forward-service-share",
NodeID: 1,
Token: "forward-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31000,
PortRangeEnd: 31010,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("forward-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "collision-share",
NodeID: 1,
Token: "collision-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31400,
PortRangeEnd: 31410,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("collision-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert local tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, now, now).Error; err != nil {
t.Fatalf("insert local forward: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
updatedShare, err := r.GetPeerShare(share.ID)
if err != nil || updatedShare == nil {
t.Fatalf("reload share: %v", err)
}
if updatedShare.CurrentFlow != 200 {
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
}
}
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-a",
NodeID: 1,
Token: "ambiguous-token-a",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31100,
PortRangeEnd: 31110,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share A: %v", err)
}
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "ambiguous-share-b",
NodeID: 1,
Token: "ambiguous-token-b",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31200,
PortRangeEnd: 31210,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share B: %v", err)
}
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
).Error; err != nil {
t.Fatalf("insert ambiguous runtimes: %v", err)
}
h := &Handler{repo: r}
h.processFlowItem(1, flowItem{N: "99_2_10_tcp", U: 120, D: 80})
updatedA, _ := r.GetPeerShare(shareA.ID)
updatedB, _ := r.GetPeerShare(shareB.ID)
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
}
}
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-runtime-share",
NodeID: 1,
Token: "cleanup-runtime-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31300,
PortRangeEnd: 31310,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
}
func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) {
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-service.db"))
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
now := time.Now().UnixMilli()
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "cleanup-forward-runtime-empty-service",
NodeID: 1,
Token: "cleanup-forward-runtime-empty-service-token",
MaxBandwidth: 0,
CurrentFlow: 0,
PortRangeStart: 31420,
PortRangeEnd: 31430,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create peer share: %v", err)
}
share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-service-token")
if err != nil || share == nil {
t.Fatalf("load peer share: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, share.ID, share.NodeID, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil {
t.Fatalf("insert peer_share_runtime with empty service name: %v", err)
}
h := &Handler{repo: r}
defer func() {
if rec := recover(); rec != nil {
t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec)
}
}()
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
}
+104 -14
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -34,6 +35,9 @@ type Handler struct {
jobsCancel context.CancelFunc
jobsStarted bool
jobsWG sync.WaitGroup
upgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
}
type loginRequest struct {
@@ -69,13 +73,21 @@ type flowItem struct {
D int64 `json:"d"`
}
const (
pngDataURLPrefix = "data:image/png;base64,"
maxBrandAssetDataURLBytes = 1024 * 1024
)
func New(repo *repo.Repository, jwtSecret string) *Handler {
return &Handler{
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
captchaTokens: make(map[string]int64),
h := &Handler{
repo: repo,
jwtSecret: jwtSecret,
wsServer: ws.NewServer(repo, jwtSecret),
captchaTokens: make(map[string]int64),
pendingUpgradeRedeploy: make(map[int64]struct{}),
}
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
return h
}
func (h *Handler) WebSocketHandler() http.Handler {
@@ -213,7 +225,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if captchaEnabled {
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
captchaID := strings.TrimSpace(req.CaptchaID)
if captchaID == "" {
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
@@ -698,7 +710,8 @@ func (h *Handler) flowConfig(w http.ResponseWriter, r *http.Request) {
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
secret := r.URL.Query().Get("secret")
if ok, _ := h.repo.NodeExistsBySecret(secret); !ok {
node, _ := h.repo.GetNodeBySecret(secret)
if node == nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
return
@@ -709,7 +722,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
var items []flowItem
if json.Unmarshal([]byte(raw), &items) == nil {
for _, item := range items {
h.processFlowItem(item)
h.processFlowItem(node.ID, item)
}
}
}
@@ -740,7 +753,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
if key == "" {
continue
}
if err := h.repo.UpsertConfig(key, v, now); err != nil {
value, err := normalizeAndValidateConfigValue(key, v)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if err := h.repo.UpsertConfig(key, value, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -760,16 +780,24 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
if strings.TrimSpace(req.Name) == "" {
name := strings.TrimSpace(req.Name)
if name == "" {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return
}
if strings.TrimSpace(req.Value) == "" {
value, err := normalizeAndValidateConfigValue(name, req.Value)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
if value == "" && name != "app_logo" && name != "app_favicon" {
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
return
}
if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil {
if err := h.repo.UpsertConfig(name, value, time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -777,6 +805,37 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OKEmpty())
}
func normalizeAndValidateConfigValue(key, value string) (string, error) {
switch strings.TrimSpace(key) {
case "app_logo", "app_favicon":
normalized := strings.TrimSpace(value)
if normalized == "" {
return "", nil
}
if !strings.HasPrefix(normalized, pngDataURLPrefix) {
return "", fmt.Errorf("品牌图片必须通过上传生成 PNG 数据")
}
if len(normalized) > maxBrandAssetDataURLBytes {
return "", fmt.Errorf("品牌图片过大,请上传更小图片")
}
payload := strings.TrimSpace(strings.TrimPrefix(normalized, pngDataURLPrefix))
if payload == "" {
return "", fmt.Errorf("品牌图片数据不能为空")
}
if _, err := base64.StdEncoding.DecodeString(payload); err != nil {
return "", fmt.Errorf("品牌图片数据格式无效")
}
return pngDataURLPrefix + payload, nil
default:
return value, nil
}
}
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -982,10 +1041,41 @@ func (h *Handler) captchaEnabled() (bool, error) {
if err != nil {
return false, err
}
if cfg == nil {
if cfg == nil || !strings.EqualFold(strings.TrimSpace(cfg.Value), "true") {
return false, nil
}
return strings.EqualFold(cfg.Value, "true"), nil
siteCfg, err := h.repo.GetConfigByName("cloudflare_site_key")
if err != nil {
return false, err
}
if siteCfg == nil || strings.TrimSpace(siteCfg.Value) == "" {
return false, nil
}
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
if err != nil {
return false, err
}
if secretCfg == nil || strings.TrimSpace(secretCfg.Value) == "" {
return false, nil
}
return true, nil
}
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
if r == nil {
return false
}
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
switch client {
case "whmcs", "whmcs-module":
return true
default:
return false
}
}
func (h *Handler) markCaptchaToken(token string) {
@@ -69,6 +69,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert expired user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert non-expiring user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
@@ -83,6 +90,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert expired user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1)
`).Error; err != nil {
t.Fatalf("insert non-expiring user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
@@ -90,6 +104,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
t.Fatalf("insert forward: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1)
`, nowMs, nowMs).Error; err != nil {
t.Fatalf("insert non-expiring forward: %v", err)
}
h.runResetAndExpiryJob(now)
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
@@ -106,4 +127,19 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
if forwardStatus != 0 {
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
}
nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`)
if nonExpUserStatus != 1 {
t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus)
}
nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`)
if nonExpTunnelStatus != 1 {
t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus)
}
nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`)
if nonExpForwardStatus != 1 {
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
}
}
+283 -110
View File
@@ -355,11 +355,28 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
id := idFromBody(r, w)
if id <= 0 {
var req struct {
ID int64 `json:"id"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
secret, err := h.repo.GetNodeSecret(id)
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
version, err := resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
secret, err := h.repo.GetNodeSecret(req.ID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("节点不存在"))
return
@@ -373,7 +390,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret)
response.WriteJSON(w, response.OK(cmd))
}
@@ -901,6 +918,58 @@ func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, er
return state, nil
}
func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return err
}
if tunnel.Type == 2 {
h.cleanupTunnelRuntime(tunnelID)
h.cleanupFederationRuntime(tunnelID)
state, err := h.reconstructTunnelState(tunnelID)
if err != nil {
return err
}
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
if fedErr != nil {
return fedErr
}
tx := h.repo.BeginTx()
if tx.Error != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
return tx.Error
}
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
tx.Rollback()
h.releaseFederationRuntimeRefs(federationReleaseRefs)
return replaceErr
}
if commitErr := tx.Commit().Error; commitErr != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
return commitErr
}
_, _, applyErr := h.applyTunnelRuntime(state)
if applyErr != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
return applyErr
}
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
return err
}
for i := range forwards {
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
return err
}
}
return nil
}
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
@@ -909,72 +978,11 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
success := 0
fail := 0
for _, tunnelID := range ids {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
fail++
continue
}
if tunnel.Type == 2 {
h.cleanupTunnelRuntime(tunnelID)
h.cleanupFederationRuntime(tunnelID)
state, err := h.reconstructTunnelState(tunnelID)
if err != nil {
fail++
continue
}
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state, h.federationLocalDomain())
if fedErr != nil {
fail++
continue
}
tx := h.repo.BeginTx()
if tx.Error != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
fail++
continue
}
if replaceErr := h.repo.ReplaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
tx.Rollback()
h.releaseFederationRuntimeRefs(federationReleaseRefs)
fail++
continue
}
if commitErr := tx.Commit().Error; commitErr != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
fail++
continue
}
_, _, applyErr := h.applyTunnelRuntime(state)
if applyErr != nil {
h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
fail++
continue
}
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
fail++
continue
}
if len(forwards) == 0 {
success++
continue
}
ok := true
for i := range forwards {
if err := h.syncForwardServices(&forwards[i], "UpdateService", true); err != nil {
ok = false
break
}
}
if ok {
success++
} else {
fail++
}
success++
}
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
}
@@ -1040,21 +1048,55 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("权限ID不能为空"))
return
}
speedID := asAnyToInt64Ptr(req["speedId"])
if err := h.validateSpeedLimitReference(speedID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
userID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)
if utErr != nil {
response.WriteJSON(w, response.Err(-2, utErr.Error()))
return
}
_, oldFlow, oldNum, oldExpTime, oldFlowReset, oldSpeedID, oldStatus, oldErr :=
h.repo.GetExistingUserTunnel(userID, tunnelID)
if oldErr != nil {
response.WriteJSON(w, response.Err(-2, oldErr.Error()))
return
}
if err := h.repo.UpdateUserTunnel(id,
asInt64(req["flow"], 0),
asInt(req["num"], 0),
asInt64(req["expTime"], time.Now().Add(365*24*time.Hour).UnixMilli()),
asInt64(req["flowResetTime"], 1),
nullableInt(asAnyToInt64Ptr(req["speedId"])),
nullableInt(speedID),
asInt(req["status"], 1),
); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
userID, tunnelID, utErr := h.repo.GetUserTunnelUserAndTunnel(id)
if utErr == nil {
h.syncUserTunnelForwards(userID, tunnelID)
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
rollbackErr := h.repo.UpdateUserTunnel(
id,
oldFlow,
int(oldNum),
oldExpTime,
oldFlowReset,
oldSpeedID,
oldStatus,
)
if rollbackErr != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败且回滚失败: %v; 回滚错误: %v", syncErr, rollbackErr)))
return
}
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("下发失败,已回滚: %v", syncErr)))
return
}
response.WriteJSON(w, response.OKEmpty())
@@ -1095,6 +1137,18 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
return
}
speedID := asAnyToInt64Ptr(req["speedId"])
if speedID != nil {
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
if speedErr != nil {
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
return
}
if !exists {
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
return
}
}
port := asInt(req["inPort"], 0)
if port <= 0 {
port = h.pickTunnelPort(tunnelID)
@@ -1119,7 +1173,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
if userName == "" {
userName = "user"
}
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port)
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, nullableInt(speedID))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -1129,7 +1183,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.syncForwardServices(createdForward, "AddService", false); err != nil {
if err := h.syncForwardServices(createdForward, "UpdateService", true); err != nil {
_ = h.deleteForwardByID(forwardID)
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
@@ -1194,6 +1248,24 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if strategy == "" {
strategy = forward.Strategy
}
speedID := asAnyToInt64Ptr(req["speedId"])
if speedID != nil {
exists, speedErr := h.repo.SpeedLimitExists(*speedID)
if speedErr != nil {
response.WriteJSON(w, response.Err(-2, speedErr.Error()))
return
}
if !exists {
response.WriteJSON(w, response.ErrDefault("限速规则不存在"))
return
}
}
newSpeedID := forward.SpeedID
if speedID != nil {
newSpeedID = sql.NullInt64{Int64: *speedID, Valid: true}
} else if _, ok := req["speedId"]; ok {
newSpeedID = sql.NullInt64{Valid: false}
}
port := asInt(req["inPort"], 0)
if port <= 0 {
@@ -1217,7 +1289,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
}
}
now := time.Now().UnixMilli()
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now); err != nil {
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
@@ -1578,29 +1650,37 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
tunnelID := asInt64(req["tunnelId"], 0)
if tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
return
}
name := asString(req["name"])
if name == "" {
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
return
}
tunnelName := h.repo.GetTunnelNameByID(tunnelID)
if tunnelName == "" {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
now := time.Now().UnixMilli()
speed := asInt(req["speed"], 100)
var tunnelID *int64
var tunnelName string
if tid := asInt64(req["tunnelId"], 0); tid > 0 {
tunnelID = &tid
tunnelName = h.repo.GetTunnelNameByID(tid)
if tunnelName == "" {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
}
now := time.Now().UnixMilli()
id, err := h.repo.CreateSpeedLimit(name, speed, tunnelID, tunnelName, now, asInt(req["status"], 1))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_ = h.sendLimiterConfig(id, speed, tunnelID)
if tunnelID != nil && *tunnelID > 0 {
_ = h.sendLimiterConfig(id, speed, *tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -1610,23 +1690,41 @@ func (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
id := asInt64(req["id"], 0)
tunnelID := asInt64(req["tunnelId"], 0)
if id <= 0 || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
if id <= 0 {
response.WriteJSON(w, response.ErrDefault("限速规则ID不能为空"))
return
}
tunnelName := h.repo.GetTunnelNameByID(tunnelID)
if tunnelName == "" {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
name := asString(req["name"])
if name == "" {
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
return
}
speed := asInt(req["speed"], 100)
if err := h.repo.UpdateSpeedLimit(id, asString(req["name"]), speed, tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli()); err != nil {
var tunnelID *int64
var tunnelName string
if tid := asInt64(req["tunnelId"], 0); tid > 0 {
tunnelID = &tid
tunnelName = h.repo.GetTunnelNameByID(tid)
if tunnelName == "" {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
}
if err := h.repo.UpdateSpeedLimit(id, name, speed, tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli()); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_ = h.sendLimiterConfig(id, speed, tunnelID)
if tunnelID != nil && *tunnelID > 0 {
_ = h.sendLimiterConfig(id, speed, *tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -1635,15 +1733,18 @@ func (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
tunnelID := h.repo.GetSpeedLimitTunnelID(id)
if err := h.repo.DeleteSpeedLimit(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if tunnelID > 0 {
_ = h.sendDeleteLimiterConfig(id, tunnelID)
if tunnelID.Valid && tunnelID.Int64 > 0 {
_ = h.sendDeleteLimiterConfig(id, tunnelID.Int64)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -2705,7 +2806,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {
allocated := map[int64]int{}
inNodes := asMapSlice(req["inNodeId"])
for _, n := range inNodes {
for i, n := range inNodes {
nodeID := asInt64(n["nodeId"], 0)
if nodeID <= 0 {
continue
@@ -2717,13 +2818,13 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
nodeID,
sql.NullInt64{},
defaultString(asString(n["strategy"]), "round"),
0,
i+1,
defaultString(asString(n["protocol"]), "tls"),
); err != nil {
return err
}
}
for _, n := range asMapSlice(req["outNodeId"]) {
for i, n := range asMapSlice(req["outNodeId"]) {
nodeID := asInt64(n["nodeId"], 0)
if nodeID <= 0 {
continue
@@ -2743,7 +2844,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
nodeID,
sql.NullInt64{Int64: int64(port), Valid: true},
defaultString(asString(n["strategy"]), "round"),
0,
i+1,
defaultString(asString(n["protocol"]), "tls"),
); err != nil {
return err
@@ -2786,7 +2887,21 @@ func (h *Handler) deleteNodeByID(id int64) error {
}
func (h *Handler) deleteTunnelByID(id int64) error {
return h.repo.DeleteTunnelCascade(id)
if h == nil || h.repo == nil {
return errors.New("repository not initialized")
}
tunnelName, _ := h.repo.GetTunnelName(id)
if err := h.repo.DeleteTunnelCascade(id); err != nil {
return err
}
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
if !ok {
return nil
}
return h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())
}
func (h *Handler) deleteForwardByID(id int64) error {
@@ -2955,6 +3070,7 @@ func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []
h.repo.RollbackForwardFields(
oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,
oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,
oldForward.SpeedID,
time.Now().UnixMilli(),
)
@@ -2976,6 +3092,10 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
h.repo.GetExistingUserTunnel(userID, tunnelID)
speedID := asAnyToInt64Ptr(req["speedId"])
if err := h.validateSpeedLimitReference(speedID); err != nil {
return err
}
reqFlow := asInt64(req["flow"], -1)
reqNum := asInt(req["num"], -1)
reqExpTime := asInt64(req["expTime"], -1)
@@ -3016,7 +3136,24 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
reqStatus = 1
}
return h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus)
if err := h.repo.InsertUserTunnel(userID, tunnelID, nullableInt(speedID), reqNum, reqFlow, reqFlowReset, reqExpTime, reqStatus); err != nil {
return err
}
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
insertedID, _, _, _, _, _, _, lookupErr := h.repo.GetExistingUserTunnel(userID, tunnelID)
if lookupErr != nil {
return fmt.Errorf("下发失败且回滚失败: %v; 回滚查询错误: %w", syncErr, lookupErr)
}
if rollbackErr := h.repo.DeleteUserTunnel(insertedID); rollbackErr != nil {
return fmt.Errorf("下发失败且回滚失败: %v; 回滚删除错误: %w", syncErr, rollbackErr)
}
return fmt.Errorf("下发失败,已回滚: %w", syncErr)
}
return nil
}
if err != nil {
return err
@@ -3054,25 +3191,61 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
newSpeedID = sql.NullInt64{Valid: false}
}
err = h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus)
if err == nil {
h.syncUserTunnelForwards(userID, tunnelID)
if err := h.repo.UpdateUserTunnelFields(existingID, newSpeedID, newFlow, newNum, newExpTime, newFlowReset, newStatus); err != nil {
return err
}
return err
if syncErr := h.syncUserTunnelForwards(userID, tunnelID); syncErr != nil {
rollbackErr := h.repo.UpdateUserTunnelFields(
existingID,
currentSpeedID,
currentFlow,
int(currentNum),
currentExpTime,
currentFlowReset,
currentStatus,
)
if rollbackErr != nil {
return fmt.Errorf("下发失败且回滚失败: %v; 回滚错误: %w", syncErr, rollbackErr)
}
return fmt.Errorf("下发失败,已回滚: %w", syncErr)
}
return nil
}
func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) {
func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
return
return err
}
for i := range forwards {
f := &forwards[i]
if f.UserID == userID {
_ = h.syncForwardServices(f, "UpdateService", true)
if err := h.syncForwardServices(f, "UpdateService", true); err != nil {
return err
}
}
}
return nil
}
func (h *Handler) validateSpeedLimitReference(speedID *int64) error {
if speedID == nil {
return nil
}
exists, err := h.repo.SpeedLimitExists(*speedID)
if err != nil {
return err
}
if !exists {
return errors.New("限速规则不存在")
}
return nil
}
func asAnySlice(v interface{}) []interface{} {
+195 -79
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -19,8 +20,104 @@ const (
githubHTMLBase = "https://github.com"
upgradeTimeout = 5 * time.Minute
batchWorkers = 5
releaseChannelStable = "stable"
releaseChannelDev = "dev"
)
var (
stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`)
)
type githubRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
func normalizeReleaseChannel(channel string) string {
switch strings.ToLower(strings.TrimSpace(channel)) {
case releaseChannelDev:
return releaseChannelDev
default:
return releaseChannelStable
}
}
func releaseChannelFromTag(tag string) string {
normalized := strings.ToLower(strings.TrimSpace(tag))
if normalized == "" {
return releaseChannelDev
}
if testKeywordPattern.MatchString(normalized) {
return releaseChannelDev
}
if stableVersionPattern.MatchString(normalized) {
return releaseChannelStable
}
return releaseChannelDev
}
func releaseChannelLabel(channel string) string {
if normalizeReleaseChannel(channel) == releaseChannelDev {
return "测试版"
}
return "正式版"
}
func fetchGitHubReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage))
if err != nil {
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var releases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
}
return releases, nil
}
func resolveLatestReleaseByChannel(channel string) (string, error) {
normalizedChannel := normalizeReleaseChannel(channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
return "", err
}
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
if releaseChannelFromTag(tag) == normalizedChannel {
return tag, nil
}
}
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int64 `json:"id"`
Version string `json:"version"`
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
@@ -67,6 +166,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err)))
return
}
h.markNodePendingUpgradeRedeploy(req.ID)
response.WriteJSON(w, response.OK(map[string]interface{}{
"version": version,
@@ -75,61 +175,11 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) {
}
func resolveLatestRelease() (string, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: 10 * time.Second,
}
resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently {
return resolveLatestReleaseAPI()
}
location := resp.Header.Get("Location")
if location == "" {
return resolveLatestReleaseAPI()
}
parts := strings.Split(location, "/")
tag := parts[len(parts)-1]
if tag == "" || tag == "latest" {
return resolveLatestReleaseAPI()
}
return tag, nil
return resolveLatestReleaseByChannel(releaseChannelStable)
}
func resolveLatestReleaseAPI() (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest")
if err != nil {
return "", fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
}
if strings.TrimSpace(release.TagName) == "" {
return "", fmt.Errorf("无法从GitHub获取最新版本号")
}
return release.TagName, nil
return resolveLatestReleaseByChannel(releaseChannelStable)
}
func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
@@ -141,6 +191,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []int64 `json:"ids"`
Version string `json:"version"`
Channel string `json:"channel"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
@@ -151,12 +202,13 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
return
}
channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestRelease()
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err)))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
@@ -195,6 +247,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) {
results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()}
return
}
h.markNodePendingUpgradeRedeploy(nodeID)
results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message}
}(i, id)
}
@@ -212,37 +265,28 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
return
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20")
var req struct {
Channel string `json:"channel"`
}
if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
channel := normalizeReleaseChannel(req.Channel)
releases, err := fetchGitHubReleases(50)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body))))
return
}
var releases []struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
PublishedAt string `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err)))
return
}
type releaseItem struct {
Version string `json:"version"`
Name string `json:"name"`
PublishedAt string `json:"publishedAt"`
Prerelease bool `json:"prerelease"`
Channel string `json:"channel"`
}
items := make([]releaseItem, 0, len(releases))
@@ -250,11 +294,20 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
itemChannel := releaseChannelFromTag(tag)
if itemChannel != channel {
continue
}
items = append(items, releaseItem{
Version: r.TagName,
Version: tag,
Name: r.Name,
PublishedAt: r.PublishedAt,
Prerelease: r.Prerelease,
Prerelease: itemChannel == releaseChannelDev,
Channel: itemChannel,
})
}
@@ -289,3 +342,66 @@ func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) {
"message": result.Message,
}))
}
func (h *Handler) markNodePendingUpgradeRedeploy(nodeID int64) {
if h == nil || nodeID <= 0 {
return
}
h.upgradeMu.Lock()
h.pendingUpgradeRedeploy[nodeID] = struct{}{}
h.upgradeMu.Unlock()
}
func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
if h == nil || nodeID <= 0 {
return false
}
h.upgradeMu.Lock()
_, ok := h.pendingUpgradeRedeploy[nodeID]
if ok {
delete(h.pendingUpgradeRedeploy, nodeID)
}
h.upgradeMu.Unlock()
return ok
}
func (h *Handler) onNodeOnline(nodeID int64) {
if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
return
}
h.redeployNodeRuntimeAfterUpgrade(nodeID)
}
func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
tunnelIDs, err := h.repo.ListActiveTunnelIDsByNode(nodeID)
if err != nil {
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
return
}
forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
if err != nil {
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
return
}
tunnelFailed := make(map[int64]struct{})
for _, tunnelID := range tunnelIDs {
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
tunnelFailed[tunnelID] = struct{}{}
fmt.Printf("post-upgrade redeploy: tunnel %d failed on node %d: %v\n", tunnelID, nodeID, err)
}
}
for _, forwardID := range forwardIDs {
forward, getErr := h.getForwardRecord(forwardID)
if getErr != nil || forward == nil {
continue
}
if _, skipped := tunnelFailed[forward.TunnelID]; skipped {
continue
}
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
fmt.Printf("post-upgrade redeploy: forward %d failed on node %d: %v\n", forwardID, nodeID, err)
}
}
}
@@ -0,0 +1,46 @@
package handler
import "testing"
func TestReleaseChannelFromTag(t *testing.T) {
tests := []struct {
name string
tag string
expects string
}{
{name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable},
{name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev},
{name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev},
{name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev},
{name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev},
{name: "non numeric tag", tag: "nightly", expects: releaseChannelDev},
{name: "empty tag", tag: "", expects: releaseChannelDev},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := releaseChannelFromTag(tc.tag); got != tc.expects {
t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects)
}
})
}
}
func TestNormalizeReleaseChannel(t *testing.T) {
tests := []struct {
input string
expects string
}{
{input: "", expects: releaseChannelStable},
{input: "stable", expects: releaseChannelStable},
{input: "dev", expects: releaseChannelDev},
{input: "DEV", expects: releaseChannelDev},
{input: "preview", expects: releaseChannelStable},
}
for _, tc := range tests {
if got := normalizeReleaseChannel(tc.input); got != tc.expects {
t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects)
}
}
}
+27 -24
View File
@@ -29,19 +29,20 @@ func (User) TableName() string { return "user" }
// Forward maps to the "forward" table.
type Forward struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id;not null"`
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
Name string `gorm:"type:varchar(100);not null"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
InFlow int64 `gorm:"column:in_flow;not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
Status int `gorm:"not null"`
Inx int `gorm:"not null;default:0"`
ID int64 `gorm:"primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id;not null"`
UserName string `gorm:"column:user_name;type:varchar(100);not null"`
Name string `gorm:"type:varchar(100);not null"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
RemoteAddr string `gorm:"column:remote_addr;type:text;not null"`
Strategy string `gorm:"type:varchar(100);not null;default:'fifo'"`
InFlow int64 `gorm:"not null;default:0"`
OutFlow int64 `gorm:"column:out_flow;not null;default:0"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime int64 `gorm:"column:updated_time;not null"`
Status int `gorm:"not null"`
Inx int `gorm:"not null;default:0"`
SpeedID sql.NullInt64 `gorm:"column:speed_id"`
}
func (Forward) TableName() string { return "forward" }
@@ -83,14 +84,14 @@ type Node struct {
func (Node) TableName() string { return "node" }
type SpeedLimit struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Speed int `gorm:"not null"`
TunnelID int64 `gorm:"column:tunnel_id;not null"`
TunnelName string `gorm:"column:tunnel_name;type:varchar(100);not null"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
ID int64 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Speed int `gorm:"not null"`
TunnelID sql.NullInt64 `gorm:"column:tunnel_id"`
TunnelName sql.NullString `gorm:"column:tunnel_name;type:varchar(100)"`
CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"`
}
func (SpeedLimit) TableName() string { return "speed_limit" }
@@ -213,7 +214,7 @@ func (GroupPermissionGrant) TableName() string { return "group_permission_grant"
type ViteConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
Value string `gorm:"type:varchar(200);not null" json:"value"`
Value string `gorm:"type:text;not null" json:"value"`
Time int64 `gorm:"not null" json:"time"`
}
@@ -395,6 +396,7 @@ type ForwardBackup struct {
UpdatedTime int64 `json:"updatedTime"`
Status int `json:"status"`
Inx int `json:"inx"`
SpeedID *int64 `json:"speedId,omitempty"`
ForwardPorts *[]ForwardPortBackup `json:"forwardPorts,omitempty"`
}
@@ -421,8 +423,8 @@ type SpeedLimitBackup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Speed int64 `json:"speed"`
TunnelID int64 `json:"tunnelId"`
TunnelName string `json:"tunnelName"`
TunnelID *int64 `json:"tunnelId,omitempty"`
TunnelName string `json:"tunnelName,omitempty"`
CreatedTime int64 `json:"createdTime"`
UpdatedTime int64 `json:"updatedTime,omitempty"`
Status int `json:"status"`
@@ -492,6 +494,7 @@ type ForwardRecord struct {
RemoteAddr string
Strategy string
Status int
SpeedID sql.NullInt64
}
// TunnelRecord is a minimal tunnel view used by control plane.
+252 -14
View File
@@ -656,7 +656,7 @@ func (r *Repository) ListUsers() ([]map[string]interface{}, error) {
return nil, errors.New("repository not initialized")
}
var users []model.User
if err := r.db.Where("role_id != ?", 0).Order("id ASC").Find(&users).Error; err != nil {
if err := r.db.Where("role_id != ?", 0).Order("id DESC").Find(&users).Error; err != nil {
return nil, err
}
items := make([]map[string]interface{}, 0, len(users))
@@ -678,17 +678,23 @@ func (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {
return nil, errors.New("repository not initialized")
}
var limits []model.SpeedLimit
if err := r.db.Order("id ASC").Find(&limits).Error; err != nil {
if err := r.db.Order("id DESC").Find(&limits).Error; err != nil {
return nil, err
}
items := make([]map[string]interface{}, 0, len(limits))
for _, sl := range limits {
items = append(items, map[string]interface{}{
item := map[string]interface{}{
"id": sl.ID, "name": sl.Name, "speed": sl.Speed,
"tunnelId": sl.TunnelID, "tunnelName": sl.TunnelName,
"status": sl.Status, "createdTime": sl.CreatedTime,
"updatedTime": nullableInt64(sl.UpdatedTime),
})
}
if sl.TunnelID.Valid {
item["tunnelId"] = sl.TunnelID.Int64
}
if sl.TunnelName.Valid {
item["tunnelName"] = sl.TunnelName.String
}
items = append(items, item)
}
return items, nil
}
@@ -712,11 +718,12 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
CreatedTime int64
Status int
Inx int
SpeedID sql.NullInt64
}
var rows []fwdRow
err := r.db.Model(&model.Forward{}).
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx").
Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id").
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Order("forward.inx ASC, forward.id ASC").
Find(&rows).Error
@@ -730,14 +737,18 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
if err != nil {
return nil, err
}
items = append(items, map[string]interface{}{
item := map[string]interface{}{
"id": row.ID, "userId": row.UserID, "userName": row.UserName,
"name": row.Name, "tunnelId": row.TunnelID, "tunnelName": row.TunnelName,
"inIp": nullableForwardIngress(inIP), "inPort": nullableInt64(inPort),
"remoteAddr": row.RemoteAddr, "strategy": row.Strategy,
"inFlow": row.InFlow, "outFlow": row.OutFlow,
"createdTime": row.CreatedTime, "status": row.Status, "inx": int64(row.Inx),
})
}
if row.SpeedID.Valid {
item["speedId"] = row.SpeedID.Int64
}
items = append(items, item)
}
return items, nil
}
@@ -1270,6 +1281,175 @@ func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64
return ports, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var items []model.PeerShareRuntime
err := r.db.Where("service_name = ? AND status = 1 AND role = ?", serviceName, "forward").
Order("id ASC").
Find(&items).Error
if err != nil {
return nil, err
}
if items == nil {
items = make([]model.PeerShareRuntime, 0)
}
return items, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
serviceName = strings.TrimSpace(serviceName)
if serviceName == "" {
return []model.PeerShareRuntime{}, nil
}
var items []model.PeerShareRuntime
err := r.db.Where("node_id = ? AND service_name = ? AND status = 1 AND role = ?", nodeID, serviceName, "forward").
Order("id ASC").
Find(&items).Error
if err != nil {
return nil, err
}
if items == nil {
items = make([]model.PeerShareRuntime, 0)
}
return items, nil
}
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var names []string
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
Pluck("service_name", &names).Error
if err != nil {
return nil, err
}
if names == nil {
names = make([]string, 0)
}
return names, nil
}
func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var item model.PeerShareRuntime
err := r.db.Where("share_id = ? AND port = ? AND status = 1 AND role = ?", shareID, port, "forward").First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
serviceName = strings.TrimSpace(serviceName)
if shareID <= 0 || serviceName == "" {
return nil, nil
}
var item model.PeerShareRuntime
err := r.db.Where("share_id = ? AND service_name = ? AND status = 1 AND role = ?", shareID, serviceName, "forward").
Order("id ASC").
First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &item, nil
}
func (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {
if r == nil || r.db == nil {
return false, errors.New("repository not initialized")
}
var count int64
err := r.db.Model(&model.PeerShareRuntime{}).
Where("node_id = ? AND port = ? AND status = 1", nodeID, port).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{
"service_name": serviceName,
"applied": 1,
"updated_time": updatedTime,
}).Error
}
func (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if shareID <= 0 || port <= 0 {
return nil
}
if updatedTime <= 0 {
updatedTime = unixMilliNow()
}
return r.db.Model(&model.PeerShareRuntime{}).Where("share_id = ? AND port = ? AND status = 1", shareID, port).Updates(map[string]interface{}{
"status": 0,
"applied": 0,
"service_name": "",
"updated_time": updatedTime,
}).Error
}
func (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
serviceName = strings.TrimSpace(serviceName)
if shareID <= 0 || serviceName == "" {
return nil
}
if updatedTime <= 0 {
updatedTime = unixMilliNow()
}
return r.db.Model(&model.PeerShareRuntime{}).
Where("share_id = ? AND status = 1 AND role = ? AND service_name = ?", shareID, "forward", serviceName).
Updates(map[string]interface{}{
"status": 0,
"applied": 0,
"service_name": "",
"updated_time": updatedTime,
}).Error
}
// ─── FederationTunnelBinding ─────────────────────────────────────────
func (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {
@@ -1643,9 +1823,15 @@ func (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {
for _, sl := range sls {
b := model.SpeedLimitBackup{
ID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),
TunnelID: sl.TunnelID, TunnelName: sl.TunnelName,
CreatedTime: sl.CreatedTime, Status: sl.Status,
}
if sl.TunnelID.Valid {
tid := sl.TunnelID.Int64
b.TunnelID = &tid
}
if sl.TunnelName.Valid {
b.TunnelName = sl.TunnelName.String
}
if sl.UpdatedTime.Valid {
b.UpdatedTime = sl.UpdatedTime.Int64
}
@@ -2016,12 +2202,18 @@ func importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now in
ID: sl.ID,
Name: sl.Name,
Speed: int(sl.Speed),
TunnelID: sl.TunnelID,
TunnelName: sl.TunnelName,
TunnelID: sql.NullInt64{Int64: 0, Valid: false},
TunnelName: sql.NullString{String: "", Valid: false},
CreatedTime: sl.CreatedTime,
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: sl.Status,
}
if sl.TunnelID != nil {
item.TunnelID = sql.NullInt64{Int64: *sl.TunnelID, Valid: true}
}
if sl.TunnelName != "" {
item.TunnelName = sql.NullString{String: sl.TunnelName, Valid: true}
}
err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
@@ -2235,7 +2427,7 @@ func (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {
}
var ids []int64
err := r.db.Model(&model.User{}).
Where("role_id != 0 AND status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).
Where("role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).
Pluck("id", &ids).Error
if err != nil {
return nil, err
@@ -2255,7 +2447,7 @@ func (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredU
return nil, errors.New("repository not initialized")
}
var uts []model.UserTunnel
err := r.db.Where("status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).Find(&uts).Error
err := r.db.Where("status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).Find(&uts).Error
if err != nil {
return nil, err
}
@@ -2290,9 +2482,10 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
// ─── Migration ───────────────────────────────────────────────────────
const currentSchemaVersion = 2
const currentSchemaVersion = 3
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
func getSchemaVersion(db *gorm.DB) int {
var v model.SchemaVersion
@@ -2344,10 +2537,55 @@ func migrateSchema(db *gorm.DB) error {
return err
}
if ver < 3 {
if err := migrateViteConfigValueColumnTypeFn(db); err != nil {
return err
}
}
setSchemaVersion(db, currentSchemaVersion)
return nil
}
func migrateViteConfigValueColumnType(db *gorm.DB) error {
if db == nil {
return errors.New("nil db")
}
if !db.Migrator().HasTable(&model.ViteConfig{}) {
return nil
}
if db.Dialector.Name() != "postgres" {
return nil
}
type columnRow struct {
DataType string `gorm:"column:data_type"`
}
var row columnRow
if err := db.Raw(
`SELECT data_type FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = ?
AND column_name = ?`,
"vite_config", "value",
).Scan(&row).Error; err != nil {
return fmt.Errorf("inspect vite_config.value type: %w", err)
}
if strings.EqualFold(row.DataType, "text") {
return nil
}
if err := db.Exec(`ALTER TABLE "vite_config" ALTER COLUMN "value" TYPE TEXT`).Error; err != nil {
return fmt.Errorf("alter vite_config.value to text: %w", err)
}
return nil
}
func ensurePostgresIDDefaults(db *gorm.DB) error {
if db.Dialector.Name() != "postgres" {
return nil
@@ -46,6 +46,7 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
@@ -56,6 +57,40 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord
return rows, nil
}
func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.ChainTunnel{}).
Joins("JOIN tunnel ON tunnel.id = chain_tunnel.tunnel_id").
Where("chain_tunnel.node_id = ? AND tunnel.status = 1", nodeID).
Select("DISTINCT chain_tunnel.tunnel_id").
Order("chain_tunnel.tunnel_id ASC").
Pluck("chain_tunnel.tunnel_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.ForwardPort{}).
Joins("JOIN forward ON forward.id = forward_port.forward_id").
Where("forward_port.node_id = ? AND forward.status = 1", nodeID).
Select("DISTINCT forward_port.forward_id").
Order("forward_port.forward_id ASC").
Pluck("forward_port.forward_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -38,6 +38,14 @@ type FederationBindingRow struct {
UpdatedTime int64
}
type ActiveForwardPortRow struct {
ForwardID int64
TunnelID int64
TunnelName string
Port int
UpdatedTime int64
}
// ListRemoteNodes returns all nodes with is_remote=1, ordered by id desc.
func (r *Repository) ListRemoteNodes() ([]RemoteNodeRow, error) {
if r == nil || r.db == nil {
@@ -87,6 +95,27 @@ func (r *Repository) ListActiveBindingsForNode(nodeID int64) ([]FederationBindin
return result, nil
}
func (r *Repository) ListActiveForwardPortsForNode(nodeID int64) ([]ActiveForwardPortRow, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var result []ActiveForwardPortRow
err := r.db.Model(&model.ForwardPort{}).
Select("forward_port.forward_id, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward_port.port, forward.updated_time").
Joins("JOIN forward ON forward.id = forward_port.forward_id").
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
Where("forward_port.node_id = ? AND forward_port.port > 0", nodeID).
Order("forward_port.port ASC, forward_port.id ASC").
Find(&result).Error
if err != nil {
return nil, err
}
if result == nil {
result = make([]ActiveForwardPortRow, 0)
}
return result, nil
}
// GetNodeBasicInfo returns the name, server_ip, and status for a given node.
func (r *Repository) GetNodeBasicInfo(nodeID int64) (*NodeBasicInfo, error) {
if r == nil || r.db == nil {
@@ -199,7 +228,6 @@ func (r *Repository) ListTunnelIDsByNamePrefix(prefix string) ([]int64, error) {
return ids, nil
}
// NextIndex returns COALESCE(MAX(inx), -1) + 1 for the given table.
func (r *Repository) NextIndex(table string) int {
if r == nil || r.db == nil {
return 0
@@ -222,7 +250,7 @@ func (r *Repository) NextIndex(table string) int {
var row inxRow
err := r.db.Model(modelRef).
Select("inx").
Order("inx DESC").
Order("inx ASC, id ASC").
Limit(1).
Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -231,10 +259,7 @@ func (r *Repository) NextIndex(table string) int {
if err != nil {
return 0
}
if row.Inx < 0 {
return 0
}
return row.Inx + 1
return row.Inx - 1
}
// CreateRemoteNode inserts a new remote node.
@@ -38,6 +38,7 @@ func (r *Repository) ListActiveForwardsByUser(userID int64) ([]model.ForwardReco
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
@@ -68,6 +69,7 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
@@ -99,6 +101,7 @@ func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, er
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
}
if strings.TrimSpace(fr.Strategy) == "" {
fr.Strategy = "fifo"
@@ -169,3 +172,15 @@ func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
}
return count > 0, nil
}
func (r *Repository) GetSpeedLimitSpeed(id int64) (int, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
var sl model.SpeedLimit
err := r.db.Select("speed").Where("id = ?", id).First(&sl).Error
if err != nil {
return 0, err
}
return sl.Speed, nil
}
@@ -83,3 +83,95 @@ func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
called := 0
originalMigrate := migrateViteConfigValueColumnTypeFn
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
called++
return nil
}
t.Cleanup(func() {
migrateViteConfigValueColumnTypeFn = originalMigrate
})
if err := migrateSchema(db); err != nil {
t.Fatalf("migrateSchema: %v", err)
}
if called != 1 {
t.Fatalf("expected vite_config migration to run once, got %d", called)
}
}
func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) {
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
t.Fatalf("create schema_version: %v", err)
}
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
t.Fatalf("seed schema_version: %v", err)
}
originalIDRepair := ensurePostgresIDDefaultsFn
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
return nil
}
t.Cleanup(func() {
ensurePostgresIDDefaultsFn = originalIDRepair
})
wantErr := errors.New("vite config migration failed")
originalMigrate := migrateViteConfigValueColumnTypeFn
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
return wantErr
}
t.Cleanup(func() {
migrateViteConfigValueColumnTypeFn = originalMigrate
})
err = migrateSchema(db)
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %v, got %v", wantErr, err)
}
}
@@ -657,7 +657,7 @@ func (r *Repository) GetMinForwardPort(forwardID int64) sql.NullInt64 {
return p
}
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64) error {
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
@@ -668,6 +668,7 @@ func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remote
"tunnel_id": tunnelID,
"remote_addr": remoteAddr,
"strategy": strategy,
"speed_id": nullInt64FromInterface(speedID),
"updated_time": now,
}).Error
}
@@ -724,7 +725,7 @@ func (r *Repository) ReplaceForwardPorts(forwardID int64, entries []struct {
})
}
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, now int64) {
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
if r == nil || r.db == nil {
return
}
@@ -738,6 +739,7 @@ func (r *Repository) RollbackForwardFields(id, userID int64, userName, name stri
"remote_addr": remoteAddr,
"strategy": strategy,
"status": status,
"speed_id": nullInt64FromInterface(speedID),
"updated_time": now,
}).Error
}
@@ -764,51 +766,66 @@ func (r *Repository) GetUsedPortsOnNodeAsMap(nodeID int64) (map[int]bool, error)
return used, nil
}
func (r *Repository) CreateSpeedLimit(name string, speed int, tunnelID int64, tunnelName string, now int64, status int) (int64, error) {
func (r *Repository) CreateSpeedLimit(name string, speed int, tunnelID *int64, tunnelName string, now int64, status int) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
sl := model.SpeedLimit{
Name: name,
Speed: speed,
TunnelID: tunnelID,
TunnelName: tunnelName,
TunnelID: sql.NullInt64{Int64: 0, Valid: false},
TunnelName: sql.NullString{String: "", Valid: false},
CreatedTime: now,
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status,
}
if tunnelID != nil {
sl.TunnelID = sql.NullInt64{Int64: *tunnelID, Valid: true}
}
if tunnelName != "" {
sl.TunnelName = sql.NullString{String: tunnelName, Valid: true}
}
if err := r.db.Create(&sl).Error; err != nil {
return 0, err
}
return sl.ID, nil
}
func (r *Repository) UpdateSpeedLimit(id int64, name string, speed int, tunnelID int64, tunnelName string, status int, now int64) error {
func (r *Repository) UpdateSpeedLimit(id int64, name string, speed int, tunnelID *int64, tunnelName string, status int, now int64) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
updates := map[string]interface{}{
"name": name,
"speed": speed,
"status": status,
"updated_time": sql.NullInt64{
Int64: now,
Valid: true,
},
}
if tunnelID != nil {
updates["tunnel_id"] = sql.NullInt64{Int64: *tunnelID, Valid: true}
} else {
updates["tunnel_id"] = sql.NullInt64{Int64: 0, Valid: false}
}
if tunnelName != "" {
updates["tunnel_name"] = sql.NullString{String: tunnelName, Valid: true}
} else {
updates["tunnel_name"] = sql.NullString{String: "", Valid: false}
}
return r.db.Model(&model.SpeedLimit{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"name": name,
"speed": speed,
"tunnel_id": tunnelID,
"tunnel_name": tunnelName,
"status": status,
"updated_time": sql.NullInt64{
Int64: now,
Valid: true,
},
}).Error
Updates(updates).Error
}
func (r *Repository) GetSpeedLimitTunnelID(speedLimitID int64) int64 {
func (r *Repository) GetSpeedLimitTunnelID(speedLimitID int64) sql.NullInt64 {
if r == nil || r.db == nil {
return 0
return sql.NullInt64{Valid: false}
}
var sl model.SpeedLimit
if err := r.db.Select("tunnel_id").Where("id = ?", speedLimitID).First(&sl).Error; err != nil {
return 0
return sql.NullInt64{Valid: false}
}
return sl.TunnelID
}
@@ -1190,7 +1207,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
return ut.ID, true, nil
}
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int) (int64, error) {
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, speedID interface{}) (int64, error) {
if r == nil || r.db == nil {
return 0, errors.New("repository not initialized")
}
@@ -1209,6 +1226,7 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
UpdatedTime: now,
Status: 1,
Inx: inx,
SpeedID: nullInt64FromInterface(speedID),
}
if err := tx.Create(&fwd).Error; err != nil {
return err
+20 -3
View File
@@ -68,9 +68,10 @@ type CommandResult struct {
}
type Server struct {
repo *repo.Repository
jwtSecret string
upgrader websocket.Upgrader
repo *repo.Repository
jwtSecret string
upgrader websocket.Upgrader
onNodeOnline func(nodeID int64)
mu sync.RWMutex
admins map[*connWrap]struct{}
@@ -79,6 +80,15 @@ type Server struct {
pending map[string]pendingRequest
}
func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) {
if s == nil {
return
}
s.mu.Lock()
s.onNodeOnline = fn
s.mu.Unlock()
}
func NewServer(repo *repo.Repository, jwtSecret string) *Server {
return &Server{
repo: repo,
@@ -183,6 +193,13 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
_ = s.repo.UpdateNodeOnline(nodeID, 1, version, httpVal, tlsVal, socksVal)
s.broadcastStatus(nodeID, 1)
s.mu.RLock()
onlineHook := s.onNodeOnline
s.mu.RUnlock()
if onlineHook != nil {
go onlineHook(nodeID)
}
defer func() {
close(done)
needOfflineBroadcast := false
@@ -193,6 +193,126 @@ func TestDiagnosisChainCoverageContracts(t *testing.T) {
})
}
func TestForwardDiagnosisRespectsTunnelIPPreferenceContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupDiagnosisContractRouter(t, secret)
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'normal_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "diagnose-ip-pref-forward", 1.0, 2, "tls", 99999, now, now, 1, nil, 0, "v6").Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "diagnose-ip-pref-forward")
insertNode := func(name, v4, v6 string) int64 {
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, name, name+"-secret", v4, v4, v6, "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node %s: %v", name, err)
}
return mustLastInsertID(t, r, name)
}
entryNodeID := insertNode("entry-node-v6", "10.10.1.10", "2001:db8:10::10")
chainNodeID := insertNode("chain-node-v6", "10.10.1.20", "2001:db8:10::20")
exitNodeID := insertNode("exit-node-v6", "10.10.1.30", "2001:db8:10::30")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert entry chain: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 2, ?, 30002, 'round', 1, 'tls')
`, tunnelID, chainNodeID).Error; err != nil {
t.Fatalf("insert middle chain: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 3, ?, 30003, 'round', 1, 'tls')
`, tunnelID, exitNodeID).Error; err != nil {
t.Fatalf("insert exit chain: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 2, "normal_user", "ip-pref-forward", tunnelID, "8.8.8.8:53", "fifo", now, now, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "ip-pref-forward")
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/diagnose", bytes.NewBufferString(`{"forwardId":`+strconv.FormatInt(forwardID, 10)+`}`))
req.Header.Set("Authorization", userToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg)
}
payload, ok := out.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected object payload, got %T", out.Data)
}
results, ok := payload["results"].([]interface{})
if !ok || len(results) == 0 {
t.Fatalf("expected non-empty results, got %v", payload["results"])
}
hasEntryToChain := false
hasChainToExit := false
for _, raw := range results {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
from := valueAsInt(item["fromChainType"])
to := valueAsInt(item["toChainType"])
targetIP := strings.TrimSpace(valueAsString(item["targetIp"]))
if from == 1 && to == 2 {
hasEntryToChain = true
if targetIP != "2001:db8:10::20" {
t.Fatalf("expected entry->chain diagnosis target to use IPv6, got %q", targetIP)
}
}
if from == 2 && to == 3 {
hasChainToExit = true
if targetIP != "2001:db8:10::30" {
t.Fatalf("expected chain->exit diagnosis target to use IPv6, got %q", targetIP)
}
}
}
if !hasEntryToChain || !hasChainToExit {
t.Fatalf("expected entry->chain and chain->exit steps, got entry=%v chain=%v", hasEntryToChain, hasChainToExit)
}
}
func TestDiagnosisUsesFederationRuntimeForRemoteNodes(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupDiagnosisContractRouter(t, secret)
@@ -166,7 +166,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, entryShareID, 1)
}
func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
@@ -0,0 +1,693 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
func TestFederationForwardCardFlowLinkageContract(t *testing.T) {
secret := "federation-forward-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "flow-local-node", "flow-local-secret", "10.20.30.40", "10.20.30.40", "", "32000-32020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert local node: %v", err)
}
nodeID := mustLastInsertID(t, r, "flow-local-node")
shareToken := "flow-linkage-share-token"
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "flow-linkage-share",
NodeID: nodeID,
Token: shareToken,
MaxBandwidth: 0,
CurrentFlow: 1536,
ExpiryTime: 0,
PortRangeStart: 32000,
PortRangeEnd: 32020,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken(shareToken)
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 32001)
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert share tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-share-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", "flow-linkage-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "flow-linkage-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 32001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
var targetForward map[string]interface{}
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == forwardID {
targetForward = m
break
}
}
if targetForward == nil {
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
}
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) != 0 {
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
}
flowByShare := make(map[int64]int64)
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
parsedShareID := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
if parsedShareID != share.ID {
t.Fatalf("expected parsed shareID=%d, got %d (tunnelName=%q)", share.ID, parsedShareID, contractValueAsString(targetForward["tunnelName"]))
}
forwardCountByShare := make(map[int64]int)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
}
shareFlow := flowByShare[parsedShareID]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for share %d", parsedShareID)
}
count := forwardCountByShare[parsedShareID]
if count <= 0 {
count = 1
}
estimated := shareFlow / int64(count)
if estimated < 1 {
estimated = 1
}
displayFlow := estimated
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 after frontend-style merge, got %d", displayFlow)
}
if displayFlow != share.CurrentFlow {
t.Fatalf("expected displayFlow=%d, got %d", share.CurrentFlow, displayFlow)
}
}
func TestFederationForwardCardFlowLinkageContractSplitShareFlowAcrossMultipleForwards(t *testing.T) {
secret := "federation-forward-split-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "flow-split-local-node", "flow-split-local-secret", "10.21.31.41", "10.21.31.41", "", "32100-32120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
t.Fatalf("insert local node: %v", err)
}
nodeID := mustLastInsertID(t, r, "flow-split-local-node")
shareToken := "flow-split-share-token"
if err := r.CreatePeerShare(&repo.PeerShare{
Name: "flow-split-share",
NodeID: nodeID,
Token: shareToken,
MaxBandwidth: 0,
CurrentFlow: 4097,
ExpiryTime: 0,
PortRangeStart: 32100,
PortRangeEnd: 32120,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
}); err != nil {
t.Fatalf("create share: %v", err)
}
share, err := r.GetPeerShareByToken(shareToken)
if err != nil || share == nil {
t.Fatalf("load share: %v", err)
}
createShareForward := func(name string, port int) int64 {
t.Helper()
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, port)
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert share tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-split-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", name, tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, name)
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
return forwardID
}
forwardIDA := createShareForward("flow-split-forward-a", 32101)
forwardIDB := createShareForward("flow-split-forward-b", 32102)
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) != 0 {
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
}
flowByShare := make(map[int64]int64)
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
shareFlow := flowByShare[share.ID]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for share %d", share.ID)
}
forwardCountByShare := make(map[int64]int)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
count := forwardCountByShare[share.ID]
if count != 2 {
t.Fatalf("expected 2 forwards sharing share %d, got %d", share.ID, count)
}
expectedEach := shareFlow / int64(count)
if expectedEach < 1 {
expectedEach = 1
}
findForward := func(forwardID int64) map[string]interface{} {
t.Helper()
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == forwardID {
return m
}
}
t.Fatalf("forward %d not found in /forward/list response", forwardID)
return nil
}
for _, forwardID := range []int64{forwardIDA, forwardIDB} {
forward := findForward(forwardID)
sid := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
if sid != share.ID {
t.Fatalf("expected parsed shareID=%d, got %d for forward %d", share.ID, sid, forwardID)
}
directFlow := contractValueAsInt64(forward["inFlow"]) + contractValueAsInt64(forward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0 for forward %d, got %d", forwardID, directFlow)
}
displayFlow := int64(0)
if directFlow > 0 {
displayFlow = directFlow
} else {
shareFlowForForward := flowByShare[sid]
if shareFlowForForward > 0 {
cnt := forwardCountByShare[sid]
if cnt <= 0 {
cnt = 1
}
estimated := shareFlowForForward / int64(cnt)
if estimated < 1 {
estimated = 1
}
displayFlow = estimated
}
}
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 for forward %d, got %d", forwardID, displayFlow)
}
if displayFlow != expectedEach {
t.Fatalf("expected displayFlow=%d for forward %d, got %d", expectedEach, forwardID, displayFlow)
}
}
}
func TestFederationForwardCardFlowLinkageContractResolvesShareByTunnelBindingWhenTunnelNameIsCustom(t *testing.T) {
secret := "federation-forward-binding-flow-contract-jwt"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
remoteShareID := int64(901)
remoteShareFlow := int64(5000)
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
"flow-binding-remote-node", "flow-binding-remote-secret", "10.31.41.51", "10.31.41.51", "", "33000-33020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", fmt.Sprintf(`{"shareId":%d,"maxBandwidth":0,"currentFlow":%d,"portRangeStart":33000,"portRangeEnd":33020}`, remoteShareID, remoteShareFlow),
).Error; err != nil {
t.Fatalf("insert remote node: %v", err)
}
remoteNodeID := mustLastInsertID(t, r, "flow-binding-remote-node")
if err := r.DB().Exec(`
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "federation-port-forward-custom-name", 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
t.Fatalf("insert custom tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "flow-binding-custom-tunnel")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, 1, "admin_user", "flow-binding-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "flow-binding-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, remoteNodeID, 33001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if forwardOut.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
}
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
if shareOut.Code != 0 {
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
}
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
if remoteUsageOut.Code != 0 {
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
}
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
if len(remoteUsageRows) == 0 {
t.Fatalf("expected non-empty remote usage rows")
}
findForward := func(id int64) map[string]interface{} {
t.Helper()
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(m["id"]) == id {
return m
}
}
t.Fatalf("forward %d not found in /forward/list response", id)
return nil
}
flowByShare := make(map[int64]int64)
shareIDsByTunnel := make(map[int64]map[int64]struct{})
for _, row := range remoteUsageRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["shareId"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
bindings, _ := m["bindings"].([]interface{})
for _, bindingRaw := range bindings {
binding, ok := bindingRaw.(map[string]interface{})
if !ok {
continue
}
tunnelIDVal := contractValueAsInt64(binding["tunnelId"])
chainType := contractValueAsInt64(binding["chainType"])
if shareID <= 0 || tunnelIDVal <= 0 {
continue
}
if chainType != 1 {
continue
}
setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]
if !ok {
setByTunnel = make(map[int64]struct{})
shareIDsByTunnel[tunnelIDVal] = setByTunnel
}
setByTunnel[shareID] = struct{}{}
}
}
for _, row := range localShareRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
shareID := contractValueAsInt64(m["id"])
currentFlow := contractValueAsInt64(m["currentFlow"])
if shareID > 0 && currentFlow > 0 {
if currentFlow > flowByShare[shareID] {
flowByShare[shareID] = currentFlow
}
}
}
targetForward := findForward(forwardID)
parsedByName := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
if parsedByName != 0 {
t.Fatalf("expected custom tunnel name cannot be parsed as Share-*-Port-*, got %d", parsedByName)
}
resolveShareIDForForward := func(forward map[string]interface{}) int64 {
candidates := make(map[int64]struct{})
shareIDFromName := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
if shareIDFromName > 0 {
candidates[shareIDFromName] = struct{}{}
}
tunnelIDVal := contractValueAsInt64(forward["tunnelId"])
if setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]; ok {
for sid := range setByTunnel {
candidates[sid] = struct{}{}
}
}
var bestShareID int64
bestFlow := int64(0)
for sid := range candidates {
flow := flowByShare[sid]
if flow > bestFlow {
bestFlow = flow
bestShareID = sid
}
}
return bestShareID
}
resolvedShareID := resolveShareIDForForward(targetForward)
if resolvedShareID != remoteShareID {
t.Fatalf("expected resolved shareID=%d via tunnel binding, got %d", remoteShareID, resolvedShareID)
}
forwardCountByShare := make(map[int64]int)
resolvedByForwardID := make(map[int64]int64)
for _, row := range forwardRows {
m, ok := row.(map[string]interface{})
if !ok {
continue
}
fid := contractValueAsInt64(m["id"])
sid := resolveShareIDForForward(m)
if sid > 0 {
resolvedByForwardID[fid] = sid
}
if sid > 0 && flowByShare[sid] > 0 {
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
}
}
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
if directFlow != 0 {
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
}
shareFlow := flowByShare[resolvedByForwardID[forwardID]]
if shareFlow <= 0 {
t.Fatalf("expected merged share flow > 0 for resolved share %d", resolvedByForwardID[forwardID])
}
count := forwardCountByShare[resolvedByForwardID[forwardID]]
if count <= 0 {
count = 1
}
estimated := shareFlow / int64(count)
if estimated < 1 {
estimated = 1
}
displayFlow := estimated
if displayFlow <= 0 {
t.Fatalf("expected displayFlow > 0 after tunnel-binding-based merge, got %d", displayFlow)
}
if displayFlow != remoteShareFlow {
t.Fatalf("expected displayFlow=%d, got %d", remoteShareFlow, displayFlow)
}
}
func requestContractEnvelope(t *testing.T, router http.Handler, token string, path string, body interface{}) response.R {
t.Helper()
payload := []byte("{}")
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body for %s: %v", path, err)
}
payload = raw
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected http 200 for %s, got %d", path, res.Code)
}
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response for %s: %v", path, err)
}
return out
}
func mustContractSlice(t *testing.T, data interface{}, label string) []interface{} {
t.Helper()
rows, ok := data.([]interface{})
if !ok {
t.Fatalf("expected %s to be []interface{}, got %T", label, data)
}
return rows
}
func contractParseShareIDFromTunnelName(tunnelName string) int64 {
normalized := strings.TrimSpace(tunnelName)
if !strings.HasPrefix(normalized, "Share-") {
return 0
}
raw := strings.TrimPrefix(normalized, "Share-")
idx := strings.Index(raw, "-Port-")
if idx <= 0 {
return 0
}
shareID, err := strconv.ParseInt(strings.TrimSpace(raw[:idx]), 10, 64)
if err != nil || shareID <= 0 {
return 0
}
return shareID
}
func contractValueAsInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case float64:
return int64(n)
case json.Number:
i, err := n.Int64()
if err == nil {
return i
}
f, err := n.Float64()
if err == nil {
return int64(f)
}
return 0
case string:
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
if err == nil {
return i
}
return 0
default:
return 0
}
}
func contractValueAsString(v interface{}) string {
s, _ := v.(string)
return s
}
@@ -2,6 +2,7 @@ package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -471,6 +472,234 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) {
}
}
func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
now := time.Now().UnixMilli()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'speed_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "forward-speed-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-speed-node", "forward-speed-secret", "10.30.0.1", "10.30.0.1", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-speed-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 31001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "forward-speed-limit-a", 2048, now, 1).Error; err != nil {
t.Fatalf("insert speed limit a: %v", err)
}
speedIDA := mustLastInsertID(t, repo, "forward-speed-limit-a")
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "forward-speed-limit-b", 4096, now, 1).Error; err != nil {
t.Fatalf("insert speed limit b: %v", err)
}
speedIDB := mustLastInsertID(t, repo, "forward-speed-limit-b")
server := httptest.NewServer(router)
defer server.Close()
stopNode := startMockNodeSession(t, server.URL, "forward-speed-secret")
defer stopNode()
createPayload := map[string]interface{}{
"name": "forward-speed-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedIDA,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
createReq.Header.Set("Authorization", adminToken)
createReq.Header.Set("Content-Type", "application/json")
createRes := httptest.NewRecorder()
router.ServeHTTP(createRes, createReq)
assertCode(t, createRes, 0)
forwardID := mustLastInsertID(t, repo, "forward-speed-target")
storedSpeed := repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
var createdSpeed sql.NullInt64
if err := storedSpeed.Scan(&createdSpeed); err != nil {
t.Fatalf("query created forward speed_id: %v", err)
}
if !createdSpeed.Valid || createdSpeed.Int64 != speedIDA {
t.Fatalf("expected created speed_id=%d, got valid=%v value=%d", speedIDA, createdSpeed.Valid, createdSpeed.Int64)
}
updateToBPayload := map[string]interface{}{
"id": forwardID,
"speedId": speedIDB,
}
updateToBBody, err := json.Marshal(updateToBPayload)
if err != nil {
t.Fatalf("marshal update-to-b payload: %v", err)
}
updateToBReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateToBBody))
updateToBReq.Header.Set("Authorization", adminToken)
updateToBReq.Header.Set("Content-Type", "application/json")
updateToBRes := httptest.NewRecorder()
router.ServeHTTP(updateToBRes, updateToBReq)
assertCode(t, updateToBRes, 0)
storedSpeed = repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
var updatedSpeed sql.NullInt64
if err := storedSpeed.Scan(&updatedSpeed); err != nil {
t.Fatalf("query updated forward speed_id: %v", err)
}
if !updatedSpeed.Valid || updatedSpeed.Int64 != speedIDB {
t.Fatalf("expected updated speed_id=%d, got valid=%v value=%d", speedIDB, updatedSpeed.Valid, updatedSpeed.Int64)
}
clearPayload := map[string]interface{}{
"id": forwardID,
"speedId": nil,
}
clearBody, err := json.Marshal(clearPayload)
if err != nil {
t.Fatalf("marshal clear payload: %v", err)
}
clearReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(clearBody))
clearReq.Header.Set("Authorization", adminToken)
clearReq.Header.Set("Content-Type", "application/json")
clearRes := httptest.NewRecorder()
router.ServeHTTP(clearRes, clearReq)
assertCode(t, clearRes, 0)
storedSpeed = repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row()
var clearedSpeed sql.NullInt64
if err := storedSpeed.Scan(&clearedSpeed); err != nil {
t.Fatalf("query cleared forward speed_id: %v", err)
}
if clearedSpeed.Valid {
t.Fatalf("expected cleared speed_id to be NULL, got %d", clearedSpeed.Int64)
}
}
func TestForwardCreateThenPauseResumeContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-toggle-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "forward-toggle-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "forward-toggle-node", "forward-toggle-secret", "10.31.0.1", "10.31.0.1", "", "41000-41010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, repo, "forward-toggle-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 41001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
server := httptest.NewServer(router)
defer server.Close()
stopNode := startMockNodeSession(t, server.URL, "forward-toggle-secret")
defer stopNode()
createPayload := map[string]interface{}{
"name": "forward-toggle-target",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
createReq.Header.Set("Authorization", adminToken)
createReq.Header.Set("Content-Type", "application/json")
createRes := httptest.NewRecorder()
router.ServeHTTP(createRes, createReq)
assertCode(t, createRes, 0)
forwardID := mustLastInsertID(t, repo, "forward-toggle-target")
pauseBody, err := json.Marshal(map[string]interface{}{"id": forwardID})
if err != nil {
t.Fatalf("marshal pause payload: %v", err)
}
pauseReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/pause", bytes.NewReader(pauseBody))
pauseReq.Header.Set("Authorization", adminToken)
pauseReq.Header.Set("Content-Type", "application/json")
pauseRes := httptest.NewRecorder()
router.ServeHTTP(pauseRes, pauseReq)
assertCode(t, pauseRes, 0)
pausedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
if pausedStatus != 0 {
t.Fatalf("expected status=0 after pause, got %d", pausedStatus)
}
resumeBody, err := json.Marshal(map[string]interface{}{"id": forwardID})
if err != nil {
t.Fatalf("marshal resume payload: %v", err)
}
resumeReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewReader(resumeBody))
resumeReq.Header.Set("Authorization", adminToken)
resumeReq.Header.Set("Content-Type", "application/json")
resumeRes := httptest.NewRecorder()
router.ServeHTTP(resumeRes, resumeReq)
assertCode(t, resumeRes, 0)
resumedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
if resumedStatus != 1 {
t.Fatalf("expected status=1 after resume, got %d", resumedStatus)
}
}
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
@@ -0,0 +1,473 @@
package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
)
func TestForwardCreateRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "limiter-fail-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "limiter-fail-node", "limiter-fail-secret", "10.20.0.1", "10.20.0.1", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "limiter-fail-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 32001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "limiter-fail-rule", 1024, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, r, "limiter-fail-rule")
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "limiter-fail-secret", map[string]string{
"addlimiters": "mock add limiters failed",
})
defer stopNode()
payload := map[string]interface{}{
"name": "limiter-fail-forward",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"speedId": speedID,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected create failure on limiter dispatch, got code=0")
}
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "limiter-fail-forward")
if forwardCount != 0 {
t.Fatalf("expected forward rollback delete on limiter failure, got count=%d", forwardCount)
}
}
func TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "addr-in-use-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "addr-in-use-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "addr-in-use-node", "addr-in-use-secret", "10.20.0.11", "10.20.0.11", "", "32100-32110", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "addr-in-use-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 32101, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "addr-in-use-secret", map[string]string{
"updateservice": "listen tcp [::]:32101: bind: address already in use",
"addservice": "listen tcp [::]:32101: bind: address already in use",
})
defer stopNode()
payload := map[string]interface{}{
"name": "addr-in-use-forward",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected create failure on address-in-use service dispatch, got code=0")
}
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "addr-in-use-forward")
if forwardCount != 0 {
t.Fatalf("expected forward rollback delete on address-in-use failure, got count=%d", forwardCount)
}
}
func TestBatchAssignRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'assign_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "assign-limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "assign-limiter-fail-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "assign-limiter-fail-node", "assign-limiter-fail-secret", "10.21.0.1", "10.21.0.1", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "assign-limiter-fail-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 33001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "assign-limiter-fail-rule", 2048, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, r, "assign-limiter-fail-rule")
if err := r.DB().Exec(`
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(21, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1)
`, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(2, 'assign_user', 'assign-limiter-fail-forward', ?, '9.9.9.9:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "assign-limiter-fail-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 33001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "assign-limiter-fail-secret", map[string]string{
"addlimiters": "mock add limiters failed",
})
defer stopNode()
assignPayload := map[string]interface{}{
"userId": 2,
"tunnels": []map[string]interface{}{{
"tunnelId": tunnelID,
"speedId": speedID,
}},
}
body, err := json.Marshal(assignPayload)
if err != nil {
t.Fatalf("marshal assign payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected assign failure on limiter dispatch, got code=0")
}
var persistedSpeedID sql.NullInt64
if err := r.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE user_id = 2 AND tunnel_id = ?`, tunnelID).Row().Scan(&persistedSpeedID); err != nil {
t.Fatalf("query user_tunnel speed_id: %v", err)
}
if persistedSpeedID.Valid {
t.Fatalf("expected speed_id rollback to NULL, got %d", persistedSpeedID.Int64)
}
}
func TestBatchAssignInsertRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(3, 'assign_insert_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "assign-insert-limiter-fail-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, r, "assign-insert-limiter-fail-tunnel")
if err := r.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "assign-insert-limiter-fail-node", "assign-insert-limiter-fail-secret", "10.22.0.1", "10.22.0.1", "", "34000-34010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
nodeID := mustLastInsertID(t, r, "assign-insert-limiter-fail-node")
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 34001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, NULL, NULL, ?, NULL, ?)
`, "assign-insert-limiter-fail-rule", 3072, now, 1).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, r, "assign-insert-limiter-fail-rule")
if err := r.DB().Exec(`
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
VALUES(3, 'assign_insert_user', 'assign-insert-limiter-fail-forward', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
`, tunnelID, now, now).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, r, "assign-insert-limiter-fail-forward")
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 34001).Error; err != nil {
t.Fatalf("insert forward_port: %v", err)
}
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "assign-insert-limiter-fail-secret", map[string]string{
"addlimiters": "mock add limiters failed",
})
defer stopNode()
assignPayload := map[string]interface{}{
"userId": 3,
"tunnels": []map[string]interface{}{{
"tunnelId": tunnelID,
"speedId": speedID,
}},
}
body, err := json.Marshal(assignPayload)
if err != nil {
t.Fatalf("marshal assign payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code == 0 {
t.Fatalf("expected assign(insert) failure on limiter dispatch, got code=0")
}
insertedCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM user_tunnel WHERE user_id = 3 AND tunnel_id = ?`, tunnelID)
if insertedCount != 0 {
t.Fatalf("expected inserted user_tunnel rollback delete, got count=%d", insertedCount)
}
}
func startMockNodeSessionWithCommandFailures(t *testing.T, baseURL string, nodeSecret string, failCommands map[string]string) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
t.Fatalf("parse provider url: %v", err)
}
if strings.EqualFold(u.Scheme, "https") {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = "/system-info"
q := u.Query()
q.Set("type", "1")
q.Set("secret", nodeSecret)
q.Set("version", "v1")
q.Set("http", "1")
q.Set("tls", "1")
q.Set("socks", "1")
u.RawQuery = q.Encode()
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
t.Fatalf("dial mock node websocket: %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
_, raw, readErr := conn.ReadMessage()
if readErr != nil {
return
}
plain := raw
var wrap struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
if cryptoErr == nil {
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
plain = []byte(dec)
}
}
}
var cmd struct {
Type string `json:"type"`
RequestID string `json:"requestId"`
}
if err := json.Unmarshal(plain, &cmd); err != nil {
continue
}
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
cmdType := strings.TrimSpace(cmd.Type)
failMsg, shouldFail := failCommands[strings.ToLower(cmdType)]
respType := fmt.Sprintf("%sResponse", cmdType)
respPayload := map[string]interface{}{
"type": respType,
"success": !shouldFail,
"message": "OK",
"requestId": cmd.RequestID,
}
if shouldFail {
if strings.TrimSpace(failMsg) == "" {
failMsg = "mock command failed"
}
respPayload["message"] = failMsg
}
respBytes, err := json.Marshal(respPayload)
if err != nil {
continue
}
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
}
}()
var stopOnce sync.Once
return func() {
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
@@ -25,6 +25,7 @@ import (
func TestCaptchaVerifyLoginContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
verifiedToken := ""
if err := r.DB().Exec(`
INSERT INTO vite_config(name, value, time)
@@ -34,7 +35,7 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
t.Fatalf("enable captcha: %v", err)
}
t.Run("login denied without verified captcha token", func(t *testing.T) {
t.Run("login allowed when cloudflare keys are missing", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
@@ -42,10 +43,10 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, -1, "验证码校验失败")
assertCode(t, resp, 0)
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
t.Run("captcha verify remains compatible without cloudflare secret", func(t *testing.T) {
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
verifyReq.Header.Set("Content-Type", "application/json")
verifyResp := httptest.NewRecorder()
@@ -65,14 +66,60 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
}
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
verifiedToken = verifyOut.Data.ValidToken
})
if err := r.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, "cloudflare_site_key", "test-site-key", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("set cloudflare site key: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, "cloudflare_secret_key", "test-secret-key", time.Now().UnixMilli()).Error; err != nil {
t.Fatalf("set cloudflare secret key: %v", err)
}
t.Run("login denied without verified captcha token", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, -1, "验证码校验失败")
})
t.Run("whmcs api client bypasses captcha", func(t *testing.T) {
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FLVX-API-Client", "whmcs")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCode(t, resp, 0)
})
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
if strings.TrimSpace(verifiedToken) == "" {
t.Fatalf("expected verified token from compatibility captcha verify")
}
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
loginReq.Header.Set("Content-Type", "application/json")
loginResp := httptest.NewRecorder()
router.ServeHTTP(loginResp, loginReq)
assertCode(t, loginResp, 0)
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody)
replayReq.Header.Set("Content-Type", "application/json")
replayResp := httptest.NewRecorder()
@@ -0,0 +1,462 @@
package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/store/repo"
)
// TestSpeedLimitWithoutTunnelContract tests that speed limits can be created without binding to a tunnel
func TestSpeedLimitWithoutTunnelContract(t *testing.T) {
secret := "contract-jwt-secret"
router, _ := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
// Create a speed limit without tunnel binding
t.Run("create speed limit without tunnel", func(t *testing.T) {
body := `{"name":"test-limit-no-tunnel","speed":100,"status":1}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/create", bytes.NewBufferString(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
// Verify the speed limit has null tunnelId
t.Run("list speed limits shows null tunnelId", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
}
data, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected data to be array, got %T", out.Data)
}
// Find our speed limit
var found bool
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] == "test-limit-no-tunnel" {
found = true
// tunnelId should be nil/not present for unbound speed limits
if tunnelID, exists := m["tunnelId"]; exists && tunnelID != nil {
t.Fatalf("expected tunnelId to be nil for unbound speed limit, got %v", tunnelID)
}
break
}
}
if !found {
t.Fatal("speed limit 'test-limit-no-tunnel' not found in list")
}
})
}
// TestSpeedLimitWithTunnelContract tests that speed limits can still be bound to tunnels
func TestSpeedLimitWithTunnelContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
// First create a tunnel
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-tunnel-for-limit")
// Create a speed limit with tunnel binding
t.Run("create speed limit with tunnel", func(t *testing.T) {
body := `{"name":"test-limit-with-tunnel","speed":200,"tunnelId":` + jsonInt(tunnelID) + `,"status":1}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/create", bytes.NewBufferString(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
// Verify the speed limit has the tunnelId
t.Run("list speed limits shows tunnelId", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
}
data, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected data to be array, got %T", out.Data)
}
var found bool
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] == "test-limit-with-tunnel" {
found = true
tunnelIDVal, exists := m["tunnelId"]
if !exists || tunnelIDVal == nil {
t.Fatal("expected tunnelId to be present for bound speed limit")
}
// Verify tunnelId matches
if tunnelIDFloat, ok := tunnelIDVal.(float64); ok {
if int64(tunnelIDFloat) != tunnelID {
t.Fatalf("expected tunnelId %d, got %d", tunnelID, int64(tunnelIDFloat))
}
}
break
}
}
if !found {
t.Fatal("speed limit 'test-limit-with-tunnel' not found in list")
}
})
}
// TestSpeedLimitUpdateTunnelBindingContract tests updating speed limit tunnel binding
func TestSpeedLimitUpdateTunnelBindingContract(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
// Create a tunnel
tunnelID := mustCreateSpeedLimitTunnel(t, r, "test-tunnel-update")
// Create a speed limit without tunnel
speedLimitID := mustCreateSpeedLimitRepo(t, r, "test-limit-update", 0)
// Update to bind to tunnel
t.Run("update speed limit to bind tunnel", func(t *testing.T) {
body := `{"id":` + jsonInt(speedLimitID) + `,"name":"test-limit-update","speed":150,"tunnelId":` + jsonInt(tunnelID) + `,"status":1}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/update", bytes.NewBufferString(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
// Verify binding
t.Run("verify tunnel binding after update", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
}
data, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected data to be array, got %T", out.Data)
}
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] == "test-limit-update" {
tunnelIDVal, exists := m["tunnelId"]
if !exists || tunnelIDVal == nil {
t.Fatal("expected tunnelId to be present after update")
}
return
}
}
t.Fatal("speed limit 'test-limit-update' not found")
})
// Update to unbind from tunnel (set tunnelId to null)
t.Run("update speed limit to unbind tunnel", func(t *testing.T) {
body := `{"id":` + jsonInt(speedLimitID) + `,"name":"test-limit-update","speed":150,"status":1}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/update", bytes.NewBufferString(body))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
// Verify unbinding
t.Run("verify tunnel unbinding after update", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/speed-limit/list", nil)
req.Header.Set("Authorization", adminToken)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
var out response.R
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected code 0, got %d", out.Code)
}
data, ok := out.Data.([]interface{})
if !ok {
t.Fatalf("expected data to be array, got %T", out.Data)
}
for _, item := range data {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if m["name"] == "test-limit-update" {
if tunnelIDVal, exists := m["tunnelId"]; exists && tunnelIDVal != nil {
t.Fatalf("expected tunnelId to be nil after unbinding, got %v", tunnelIDVal)
}
return
}
}
t.Fatal("speed limit 'test-limit-update' not found")
})
}
// TestSpeedLimitDatabaseNullableFields tests database-level nullable fields
func TestSpeedLimitDatabaseNullableFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-null.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
// Create speed limit via repository
t.Run("repository create speed limit without tunnel", func(t *testing.T) {
id, err := r.CreateSpeedLimit("db-test-limit", 100, nil, "", 1, 1)
if err != nil {
t.Fatalf("CreateSpeedLimit failed: %v", err)
}
if id <= 0 {
t.Fatalf("expected valid id, got %d", id)
}
})
// Verify TunnelID is null in database
t.Run("verify null TunnelID in database", func(t *testing.T) {
var tunnelID sql.NullInt64
var tunnelName sql.NullString
err := r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE name = ?", "db-test-limit").Row().Scan(&tunnelID, &tunnelName)
if err != nil {
t.Fatalf("query failed: %v", err)
}
if tunnelID.Valid {
t.Fatalf("expected TunnelID to be NULL, got %d", tunnelID.Int64)
}
if tunnelName.Valid && tunnelName.String != "" {
t.Fatalf("expected TunnelName to be NULL or empty, got %s", tunnelName.String)
}
})
// Create a tunnel for binding test
tunnelID := mustCreateSpeedLimitTunnel(t, r, "db-test-tunnel")
// Create speed limit with tunnel
t.Run("repository create speed limit with tunnel", func(t *testing.T) {
id, err := r.CreateSpeedLimit("db-test-limit-with-tunnel", 200, &tunnelID, "db-test-tunnel", 1, 1)
if err != nil {
t.Fatalf("CreateSpeedLimit failed: %v", err)
}
if id <= 0 {
t.Fatalf("expected valid id, got %d", id)
}
})
// Verify TunnelID is set
t.Run("verify TunnelID is set in database", func(t *testing.T) {
var dbTunnelID sql.NullInt64
var dbTunnelName sql.NullString
err := r.DB().Raw("SELECT tunnel_id, tunnel_name FROM speed_limit WHERE name = ?", "db-test-limit-with-tunnel").Row().Scan(&dbTunnelID, &dbTunnelName)
if err != nil {
t.Fatalf("query failed: %v", err)
}
if !dbTunnelID.Valid {
t.Fatal("expected TunnelID to be valid")
}
if dbTunnelID.Int64 != tunnelID {
t.Fatalf("expected TunnelID %d, got %d", tunnelID, dbTunnelID.Int64)
}
if !dbTunnelName.Valid || dbTunnelName.String != "db-test-tunnel" {
t.Fatalf("expected TunnelName 'db-test-tunnel', got %v", dbTunnelName.String)
}
})
// Test GetSpeedLimitTunnelID returns correct nullability
t.Run("GetSpeedLimitTunnelID returns null for unbound limit", func(t *testing.T) {
result := r.GetSpeedLimitTunnelID(1) // First speed limit (db-test-limit)
if result.Valid {
t.Fatalf("expected GetSpeedLimitTunnelID to return invalid/null, got valid with value %d", result.Int64)
}
})
t.Run("GetSpeedLimitTunnelID returns value for bound limit", func(t *testing.T) {
result := r.GetSpeedLimitTunnelID(2) // Second speed limit (db-test-limit-with-tunnel)
if !result.Valid {
t.Fatal("expected GetSpeedLimitTunnelID to return valid result for bound limit")
}
if result.Int64 != tunnelID {
t.Fatalf("expected TunnelID %d, got %d", tunnelID, result.Int64)
}
})
}
// TestSpeedLimitUpdateUnbindFromTunnel tests unbinding a speed limit from a tunnel
func TestSpeedLimitUpdateUnbindFromTunnel(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-unbind.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
// Create tunnel
tunnelID := mustCreateSpeedLimitTunnel(t, r, "unbind-test-tunnel")
// Create speed limit bound to tunnel
speedLimitID, err := r.CreateSpeedLimit("unbind-test-limit", 300, &tunnelID, "unbind-test-tunnel", 1, 1)
if err != nil {
t.Fatalf("create speed limit: %v", err)
}
// Verify initial binding
t.Run("verify initial binding", func(t *testing.T) {
result := r.GetSpeedLimitTunnelID(speedLimitID)
if !result.Valid {
t.Fatal("expected initial binding to tunnel")
}
if result.Int64 != tunnelID {
t.Fatalf("expected tunnel ID %d, got %d", tunnelID, result.Int64)
}
})
// Update to unbind
t.Run("unbind speed limit from tunnel via UpdateSpeedLimit", func(t *testing.T) {
err := r.UpdateSpeedLimit(speedLimitID, "unbind-test-limit", 300, nil, "", 1, time.Now().UnixMilli())
if err != nil {
t.Fatalf("UpdateSpeedLimit failed: %v", err)
}
})
// Verify unbinding
t.Run("verify unbinding after update", func(t *testing.T) {
result := r.GetSpeedLimitTunnelID(speedLimitID)
if result.Valid {
t.Fatalf("expected GetSpeedLimitTunnelID to return invalid/null after unbind, got valid with value %d", result.Int64)
}
})
}
// TestSpeedLimitGetSpeed tests the GetSpeedLimitSpeed function
func TestSpeedLimitGetSpeed(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "speed-limit-getspeed.db")
r, err := repo.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = r.Close() })
// Create speed limit
speedLimitID, err := r.CreateSpeedLimit("get-speed-test", 500, nil, "", 1, 1)
if err != nil {
t.Fatalf("create speed limit: %v", err)
}
// Test GetSpeedLimitSpeed
t.Run("GetSpeedLimitSpeed returns correct speed", func(t *testing.T) {
speed, err := r.GetSpeedLimitSpeed(speedLimitID)
if err != nil {
t.Fatalf("GetSpeedLimitSpeed failed: %v", err)
}
if speed != 500 {
t.Fatalf("expected speed 500, got %d", speed)
}
})
t.Run("GetSpeedLimitSpeed returns error for non-existent id", func(t *testing.T) {
_, err := r.GetSpeedLimitSpeed(99999)
if err == nil {
t.Fatal("expected error for non-existent speed limit ID")
}
})
}
// Helper functions
func mustCreateSpeedLimitTunnel(t *testing.T, r *repo.Repository, name string) int64 {
t.Helper()
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
`, name, now, now).Error; err != nil {
t.Fatalf("create tunnel failed: %v", err)
}
return mustLastInsertID(t, r, name)
}
func mustCreateSpeedLimitRepo(t *testing.T, r *repo.Repository, name string, tunnelID int64) int64 {
t.Helper()
now := time.Now().UnixMilli()
var tid *int64
if tunnelID > 0 {
tid = &tunnelID
}
id, err := r.CreateSpeedLimit(name, 100, tid, "", now, 1)
if err != nil {
t.Fatalf("create speed limit failed: %v", err)
}
return id
}
+7 -5
View File
@@ -1,6 +1,6 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Thu Feb 26 2026
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
@@ -19,16 +19,18 @@ go-gost/
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Panel integration config | `go-gost/config.go` | Expects `config.json` in cwd by default |
| Service lifecycle/reload | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| WebSocket reporting | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| Protocol behaviors | `go-gost/x/` | Handlers/listeners/dialers live here |
| **Panel integration config** | `go-gost/config.go` | Expects `config.json` in cwd by default |
| **Service lifecycle/reload** | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| **WebSocket reporting** | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| **Protocol behaviors** | `go-gost/x/` | Handlers/listeners/dialers live here |
| **Build** | `go-gost/Makefile` | Cross-compile targets for amd64/arm64 |
## CONVENTIONS
- Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
- `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
- Agent communicates with panel via WebSocket (real-time commands) + HTTP (batch traffic reports).
- All panel communication uses AES encryption with node `secret` as PSK.
- CI builds with `CGO_ENABLED=0` for static binaries, then compresses with UPX.
## ANTI-PATTERNS
- **DO NOT EDIT** generated protobuf in `x/internal/util/grpc/proto/`.
+11 -10
View File
@@ -6,27 +6,28 @@ Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/
## STRUCTURE
```
go-gost/x/
├── api/ # Gin management API + embedded swagger docs
├── api/ # Gin management API + embedded swagger docs (22 files)
├── config/ # Config model + parsing/load/reload
├── connector/ # Outbound connect implementations
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
├── limiter/ # Traffic/rate/conn limiters
├── registry/ # Registries for services/handlers/listeners/etc
├── registry/ # Registries for services/handlers/listeners/etc (20 files)
├── service/ # Service wrappers + reporting hooks
├── socket/ # WebSocket reporter / panel integration
├── socket/ # WebSocket reporter / panel integration (6 files)
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Management API routes/auth | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| Service config parsing | `go-gost/x/config/parsing/` | Converts config to running services |
| Add a handler | `go-gost/x/handler/` | Per-protocol subdirs |
| Add a listener/dialer | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| Panel reporting | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
| **Management API routes/auth** | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| **Service config parsing** | `go-gost/x/config/parsing/` | Converts config to running services |
| **Add a handler** | `go-gost/x/handler/` | Per-protocol subdirs |
| **Add a listener/dialer** | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| **Panel reporting** | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
| **Register new component** | `go-gost/x/registry/` | `Register{Type}(name, creator)` |
## CONVENTIONS
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
@@ -41,4 +42,4 @@ go-gost/x/
```bash
cd go-gost/x
go test ./...
```
```
+2 -1
View File
@@ -1 +1,2 @@
VITE_API_BASE=
VITE_GITHUB_REPO=https://github.com/Sagit-chu/flvx
VITE_APP_VERSION=beta
+11 -12
View File
@@ -1,9 +1,9 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Thu Feb 19 2026
**Commit:** 137c34e
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Branch:** main
**Tag:** 2.1.4-rc2
**Tag:** 2.1.5-rc15
## OVERVIEW
Web management console for FLVX.
@@ -15,8 +15,8 @@ vite-frontend/
├── src/
│ ├── api/ # Axios wrapper + typed endpoint helpers
│ ├── components/ui/ # shadcn/radix primitive components
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade used by pages/layouts
│ ├── pages/ # Route views + page modules (forward/node/tunnel split helpers)
│ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade (23 components)
│ ├── pages/ # Route views + page modules (forward/node/tunnel)
│ ├── hooks/ # H5/WebView/mobile hooks
│ ├── styles/
│ │ ├── globals.css # Base styles + imports tailwind-theme.pcss
@@ -25,8 +25,8 @@ vite-frontend/
│ ├── main.tsx # ReactDOM + BrowserRouter + Provider
│ └── provider.tsx # Toast/theme/provider composition
├── components.json # shadcn/ui config
├── tailwind.config.js # Compatibility config still used by migration scaffolding
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
├── tailwind.config.js # Compatibility config for migration scaffolding
├── vite.config.ts # base '/', host 0.0.0.0:3000; minify/treeshake disabled
└── package.json
```
@@ -47,8 +47,8 @@ vite-frontend/
- **API Envelope**: Responses follow `{code, msg, data, ts}`.
- **UI Imports**: Use `src/shadcn-bridge/heroui/*` in app pages/layouts for compatibility.
- **Semantic Colors**: Keep `globals.css -> tailwind-theme.pcss` import intact or semantic classes break.
- **Build profile**: `minify: false`, `treeshake: false` for easier debugging.
- **Layout mode**: H5/mobile mode still controlled by existing route/query and hook logic.
- **Build profile**: `minify: false`, `treeshake: false` for debugging.
- **Layout mode**: H5/mobile mode controlled by existing route/query and hook logic.
## ANTI-PATTERNS
- **DO NOT ADD** `Bearer` to auth header in frontend requests.
@@ -57,10 +57,9 @@ vite-frontend/
- **DO NOT ADD** frontend tests; no Vitest/Jest setup exists.
## NOTES
- PR `#144` (shadcn migration) and PR `#142` (user-group binding) are merged in `main`.
- Release tag `2.1.4-rc2` points to commit `137c34e`.
- Button border/color parity depends on both bridge mapping and semantic Tailwind token export.
- Uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
- Build outputs are non-minified (debugging mode).
- No test infrastructure exists (Vitest/Jest not configured).
## COMMANDS
```bash
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 114 KiB

+75 -2
View File
@@ -2,12 +2,86 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="FLVX" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>FLVX</title>
<script>
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
(function() {
// 页面最早阶段应用缓存 favicon,避免默认图标闪烁
const defaultFavicon = '/favicon.ico';
let cachedFavicon = '';
try {
cachedFavicon = localStorage.getItem('vite_config_app_favicon') || '';
} catch (_) {
cachedFavicon = '';
}
// 无缓存时,尝试通过公开配置接口同步读取,避免登录页闪烁
if (!cachedFavicon.trim()) {
try {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/v1/config/get', false);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ name: 'app_favicon' }));
if (xhr.status === 200) {
const resp = JSON.parse(xhr.responseText || '{}');
const value =
resp &&
typeof resp === 'object' &&
resp.code === 0 &&
resp.data &&
typeof resp.data.value === 'string'
? resp.data.value.trim()
: '';
if (value) {
cachedFavicon = value;
try {
localStorage.setItem('vite_config_app_favicon', value);
} catch (_) {}
}
}
} catch (_) {}
}
const faviconHref = cachedFavicon.trim() || defaultFavicon;
let faviconLink = document.head.querySelector('link#app-favicon');
if (!faviconLink) {
faviconLink = document.createElement('link');
faviconLink.id = 'app-favicon';
faviconLink.rel = 'icon';
document.head.appendChild(faviconLink);
}
faviconLink.href = faviconHref;
if (faviconHref.startsWith('data:image/png')) {
faviconLink.type = 'image/png';
} else {
faviconLink.removeAttribute('type');
}
let shortcutIconLink = document.head.querySelector('link[rel="shortcut icon"]');
if (!shortcutIconLink) {
shortcutIconLink = document.createElement('link');
shortcutIconLink.rel = 'shortcut icon';
document.head.appendChild(shortcutIconLink);
}
shortcutIconLink.href = faviconHref;
if (faviconHref.startsWith('data:image/png')) {
shortcutIconLink.type = 'image/png';
} else {
shortcutIconLink.removeAttribute('type');
}
// 立即检测系统主题并应用
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -151,7 +225,6 @@
content="viewport-fit=cover, width=device-width, initial-scale=1.0, user-scalable=no"
name="viewport"
/>
<link href="/favicon.ico" rel="icon" />
</head>
<body>
<div id="root"></div>
+6 -2
View File
@@ -13,6 +13,8 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@internationalized/date": "^3.10.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
@@ -25,8 +27,6 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@marsidev/react-turnstile": "^1.1.0",
"@internationalized/date": "^3.10.0",
"@react-aria/i18n": "^3.12.12",
"@react-aria/visually-hidden": "3.8.25",
"@react-types/shared": "3.30.0",
@@ -43,8 +43,11 @@
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "6.30.3",
"recharts": "^3.1.1",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
"tailwind-merge": "^2.5.5",
"tailwind-variants": "1.0.0",
@@ -75,6 +78,7 @@
"prettier": "3.5.3",
"typescript": "5.6.3",
"vite": "npm:rolldown-vite@^7.3.1",
"vite-plugin-pwa": "^1.1.0",
"vite-tsconfig-paths": "^6.0.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

+8 -14
View File
@@ -18,7 +18,7 @@ import AdminLayout from "@/layouts/admin";
import H5Layout from "@/layouts/h5";
import H5SimpleLayout from "@/layouts/h5-simple";
import { isLoggedIn } from "@/utils/auth";
import { siteConfig } from "@/config/site";
import { siteConfig, updateSiteConfig } from "@/config/site";
import { useH5Mode } from "@/hooks/useH5Mode";
// 简化的路由保护组件 - 使用 React Router 导航避免循环
@@ -90,23 +90,17 @@ function App() {
useEffect(() => {
document.title = siteConfig.name;
// 异步检查是否有配置更新
const checkTitleUpdate = async () => {
try {
// 引入必要的函数
const { getCachedConfig } = await import("@/config/site");
const cachedAppName = await getCachedConfig("app_name");
void updateSiteConfig();
if (cachedAppName && cachedAppName !== document.title) {
document.title = cachedAppName;
}
} catch {}
const handleConfigUpdate = () => {
void updateSiteConfig();
};
// 延迟检查,避免阻塞初始渲染
const timer = setTimeout(checkTitleUpdate, 100);
window.addEventListener("configUpdated", handleConfigUpdate);
return () => clearTimeout(timer);
return () => {
window.removeEventListener("configUpdated", handleConfigUpdate);
};
}, []);
return (
+20 -8
View File
@@ -31,6 +31,8 @@ import axios from "axios";
import Network from "./network";
export type ReleaseChannel = "stable" | "dev";
// 登陆相关接口
export interface LoginData {
username: string;
@@ -66,8 +68,10 @@ export const getNodeList = () => Network.post<NodeApiItem[]>("/node/list");
export const updateNode = (data: NodeMutationPayload) =>
Network.post("/node/update", data);
export const deleteNode = (id: number) => Network.post("/node/delete", { id });
export const getNodeInstallCommand = (id: number) =>
Network.post<string>("/node/install", { id });
export const getNodeInstallCommand = (
id: number,
channel: ReleaseChannel = "stable",
) => Network.post<string>("/node/install", { id, channel });
export const updateNodeOrder = (data: {
nodes: Array<{ id: number; inx: number }>;
}) => Network.post("/node/update-order", data);
@@ -77,20 +81,28 @@ export const checkNodeStatus = (nodeId?: number) => {
return Network.post("/node/check-status", params);
};
export const upgradeNode = (id: number, version?: string) =>
export const upgradeNode = (
id: number,
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post(
"/node/upgrade",
{ id, version: version || "" },
{ id, version: version || "", channel },
{ timeout: 5 * 60 * 1000 },
);
export const batchUpgradeNodes = (ids: number[], version?: string) =>
export const batchUpgradeNodes = (
ids: number[],
version?: string,
channel: ReleaseChannel = "stable",
) =>
Network.post(
"/node/batch-upgrade",
{ ids, version: version || "" },
{ ids, version: version || "", channel },
{ timeout: 15 * 60 * 1000 },
);
export const getNodeReleases = () =>
Network.post<NodeReleaseApiItem[]>("/node/releases");
export const getNodeReleases = (channel: ReleaseChannel = "stable") =>
Network.post<NodeReleaseApiItem[]>("/node/releases", { channel });
export const rollbackNode = (id: number) =>
Network.post("/node/rollback", { id });
+5 -2
View File
@@ -51,6 +51,7 @@ export interface ForwardApiItem {
outFlow?: number;
userId?: number;
tunnelId?: number;
speedId?: number | null;
inx?: number;
[key: string]: unknown;
}
@@ -96,10 +97,10 @@ export interface StatisticsFlowApiItem {
export interface SpeedLimitApiItem {
id: number;
name: string;
tunnelId: number;
tunnelId?: number | null;
speed: number;
status: number;
tunnelName: string;
tunnelName?: string;
createdTime: string;
updatedTime: string;
uploadSpeed?: number;
@@ -172,6 +173,7 @@ export interface NodeReleaseApiItem {
name: string;
publishedAt: string;
prerelease: boolean;
channel: "stable" | "dev";
}
export interface UserPackageInfoApiData {
@@ -283,6 +285,7 @@ export interface ForwardMutationPayload {
inPort?: number | null;
remoteAddr?: string;
strategy?: string;
speedId?: number | null;
}
export interface SpeedLimitMutationPayload {
@@ -0,0 +1,122 @@
import { motion } from "framer-motion";
import React from "react";
/**
* Wraps page content with a smooth fade-in + slide-up entrance animation.
* Use this as the outermost wrapper inside each page component.
*/
export const AnimatedPage = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={className}
exit={{ opacity: 0, y: -8 }}
initial={{ opacity: 0, y: 16 }}
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{children}
</motion.div>
);
/**
* Stagger container — apply to the parent of a list/grid of animated items.
* Children should use `staggerItem` as their `variants` prop.
*/
export const staggerContainer = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.05,
},
},
};
/**
* Individual stagger item variant — fade-in + slide-up.
*/
export const staggerItem = {
hidden: { opacity: 0, y: 12 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
},
};
/**
* Convenience wrapper for a stagger list.
* Renders a `motion.div` (or `motion.ul/motion.tbody`) with stagger behaviour.
*/
export const StaggerList = ({
children,
className,
as = "div",
}: {
children: React.ReactNode;
className?: string;
as?: "div" | "ul" | "tbody";
}) => {
const Component = motion[as] as React.ElementType;
return (
<Component
animate="show"
className={className}
initial="hidden"
variants={staggerContainer}
>
{children}
</Component>
);
};
/**
* Individual animated item for use inside StaggerList.
*/
export const StaggerItem = ({
children,
className,
as = "div",
}: {
children: React.ReactNode;
className?: string;
as?: "div" | "li" | "tr";
}) => {
const Component = motion[as] as React.ElementType;
return (
<Component className={className} variants={staggerItem}>
{children}
</Component>
);
};
/**
* Simple fade-in animation for standalone elements (chips, badges, counters, etc.)
* Uses opacity + translateY to avoid font blurriness caused by scale transforms.
*/
export const FadeIn = ({
children,
delay = 0,
className,
}: {
children: React.ReactNode;
delay?: number;
className?: string;
}) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={className}
initial={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2, delay, ease: "easeOut" }}
>
{children}
</motion.div>
);
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { siteConfig, getCachedConfigs } from "@/config/site";
import { Logo } from "@/components/icons";
type BrandLogoProps = {
size?: number;
className?: string;
alt?: string;
};
export const BrandLogo = ({
size = 24,
className,
alt = "brand logo",
}: BrandLogoProps) => {
const [logoUrl, setLogoUrl] = useState<string>(siteConfig.app_logo || "");
const [imageFailed, setImageFailed] = useState(false);
useEffect(() => {
let mounted = true;
const syncLogo = async () => {
try {
const configMap = await getCachedConfigs();
const nextLogo = (
configMap.app_logo ||
siteConfig.app_logo ||
""
).trim();
if (!mounted) {
return;
}
siteConfig.app_logo = nextLogo;
setLogoUrl(nextLogo);
setImageFailed(false);
} catch {
if (!mounted) {
return;
}
setLogoUrl((siteConfig.app_logo || "").trim());
}
};
void syncLogo();
const handleConfigUpdate = () => {
const nextLogo = (siteConfig.app_logo || "").trim();
setLogoUrl(nextLogo);
setImageFailed(false);
void syncLogo();
};
window.addEventListener("configUpdated", handleConfigUpdate);
return () => {
mounted = false;
window.removeEventListener("configUpdated", handleConfigUpdate);
};
}, []);
if (!logoUrl || imageFailed) {
return <Logo className={className} size={size} />;
}
return (
<img
alt={alt}
className={className}
height={size}
src={logoUrl}
width={size}
onError={() => setImageFailed(true)}
/>
);
};
+4 -4
View File
@@ -1,13 +1,13 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link } from "@/shadcn-bridge/heroui/link";
import {
Navbar as HeroUINavbar,
NavbarBrand,
NavbarContent,
} from "@/shadcn-bridge/heroui/navbar";
import { useNavigate } from "react-router-dom";
import { Logo } from "@/components/icons";
import { BrandLogo } from "@/components/brand-logo";
import { siteConfig, getCachedConfig } from "@/config/site";
import { useWebViewMode } from "@/hooks/useWebViewMode";
@@ -69,7 +69,7 @@ export const Navbar = () => {
color="foreground"
href="/"
>
<Logo size={24} />
<BrandLogo size={24} />
<p className="font-bold text-inherit truncate">{appName}</p>
</Link>
</NavbarBrand>
+100
View File
@@ -0,0 +1,100 @@
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { SearchIcon } from "@/components/icons";
interface SearchBarProps {
isVisible: boolean;
value: string;
placeholder?: string;
onOpen: () => void;
onClose: () => void;
onChange: (value: string) => void;
}
export function SearchBar({
isVisible,
value,
placeholder = "搜索",
onOpen,
onClose,
onChange,
}: SearchBarProps) {
return (
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
<div className="flex items-center gap-2 h-8 overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{!isVisible ? (
<motion.div
key="search-btn"
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.12 }}
>
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={onOpen}
>
<SearchIcon className="w-4 h-4" />
</Button>
</motion.div>
) : (
<motion.div
key="search-input"
animate={{ opacity: 1, x: 0 }}
className="flex w-full items-center gap-2"
exit={{ opacity: 0, x: -8 }}
initial={{ opacity: 0, x: -16 }}
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Input
classNames={{
base: "bg-default-100",
input:
"bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
inputWrapper: "bg-default-100 border-0 shadow-none h-8 min-h-8",
}}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
onClose();
onChange("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -1,4 +1,5 @@
import React, { useEffect } from "react";
import { useTheme } from "@/shadcn-bridge/heroui/use-theme";
interface ThemeProviderProps {
+26 -10
View File
@@ -9,9 +9,12 @@ const alertVariants = cva(
variants: {
variant: {
default: "border-default-200 bg-default-50/70 text-foreground",
destructive: "border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300",
success: "border-success-200 bg-success-50 text-success-700 dark:text-success-300",
warning: "border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300",
destructive:
"border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300",
success:
"border-success-200 bg-success-50 text-success-700 dark:text-success-300",
warning:
"border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300",
},
},
defaultVariants: {
@@ -35,20 +38,33 @@ function Alert({
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
function AlertTitle({
className,
children,
...props
}: React.ComponentProps<"h5">) {
return (
<h5
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
data-slot="alert-title"
{...props}
>
{children}
</h5>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm opacity-90", className)}
data-slot="alert-description"
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("text-sm opacity-90", className)} data-slot="alert-description" {...props} />
);
}
export { Alert, AlertDescription, AlertTitle };
+10 -6
View File
@@ -5,17 +5,21 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors transition-transform duration-100 active:scale-95 disabled:active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-danger text-white hover:bg-danger/90",
outline: "border border-input bg-background hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
outline:
"border border-input bg-background hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-default-100 hover:text-foreground dark:hover:bg-default-200/40",
link: "text-primary underline-offset-4 hover:underline",
light: "bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40",
light:
"bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40",
flat: "bg-default-100 text-foreground hover:bg-default-200 dark:bg-default-100/40",
},
size: {
@@ -43,9 +47,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
return (
<Comp
ref={ref}
className={cn(buttonVariants({ className, size, variant }))}
data-slot="button"
ref={ref}
{...props}
/>
);
+25 -5
View File
@@ -25,13 +25,22 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
function CardTitle({
className,
children,
...props
}: React.ComponentProps<"h3">) {
return (
<h3
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
data-slot="card-title"
{...props}
/>
>
{children}
</h3>
);
}
@@ -47,7 +56,11 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("p-6 pt-0", className)} data-slot="card-content" {...props} />
<div
className={cn("p-6 pt-0", className)}
data-slot="card-content"
{...props}
/>
);
}
@@ -61,4 +74,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
);
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+6 -3
View File
@@ -4,18 +4,21 @@ import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="flex items-center justify-center text-current"
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150"
data-slot="checkbox-indicator"
>
<CheckIcon className="h-3.5 w-3.5" />
+34 -13
View File
@@ -4,23 +4,34 @@ import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
@@ -46,7 +57,7 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg dark:bg-default-50",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2 sm:rounded-lg dark:bg-default-50",
className,
)}
data-slot="dialog-content"
@@ -54,9 +65,7 @@ function DialogContent({
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -69,7 +78,10 @@ function DialogContent({
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
data-slot="dialog-header"
{...props}
/>
@@ -79,17 +91,26 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
data-slot="dialog-footer"
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
data-slot="dialog-title"
{...props}
/>
@@ -4,32 +4,54 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuSubTrigger({
@@ -63,7 +85,7 @@ function DropdownMenuSubContent({
return (
<DropdownMenuPrimitive.SubContent
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
className,
)}
data-slot="dropdown-menu-sub-content"
@@ -81,7 +103,7 @@ function DropdownMenuContent({
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
"z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
className,
)}
data-slot="dropdown-menu-content"
@@ -171,7 +193,11 @@ function DropdownMenuLabel({
}) {
return (
<DropdownMenuPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
data-slot="dropdown-menu-label"
{...props}
/>
@@ -191,10 +217,16 @@ function DropdownMenuSeparator({
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-default-500", className)}
className={cn(
"ml-auto text-xs tracking-widest text-default-500",
className,
)}
data-slot="dropdown-menu-shortcut"
{...props}
/>
+4 -1
View File
@@ -3,7 +3,10 @@ import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
className={cn(
+8 -2
View File
@@ -13,13 +13,19 @@ function Progress({
}) {
return (
<ProgressPrimitive.Root
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-default-200", className)}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-default-200",
className,
)}
data-slot="progress"
value={value}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
className={cn(
"h-full w-full flex-1 bg-primary transition-all",
indicatorClassName,
)}
data-slot="progress-indicator"
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
@@ -4,7 +4,10 @@ import { CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
@@ -27,7 +30,10 @@ function RadioGroupItem({
data-slot="radio-group-item"
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center" data-slot="radio-group-indicator">
<RadioGroupPrimitive.Indicator
className="flex items-center justify-center"
data-slot="radio-group-indicator"
>
<CircleIcon className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
+22 -7
View File
@@ -4,15 +4,21 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
@@ -44,7 +50,10 @@ function SelectScrollUpButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
data-slot="select-scroll-up-button"
{...props}
>
@@ -59,7 +68,10 @@ function SelectScrollDownButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
data-slot="select-scroll-down-button"
{...props}
>
@@ -78,7 +90,7 @@ function SelectContent({
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-default-50",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -103,7 +115,10 @@ function SelectContent({
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
+4 -1
View File
@@ -3,7 +3,10 @@ import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
className={cn(
+26 -6
View File
@@ -5,7 +5,11 @@ import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div className="relative w-full overflow-auto" data-slot="table-wrapper">
<table className={cn("w-full caption-bottom text-sm", className)} data-slot="table" {...props} />
<table
className={cn("w-full caption-bottom text-sm", className)}
data-slot="table"
{...props}
/>
</div>
);
}
@@ -33,7 +37,10 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
className={cn("border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0", className)}
className={cn(
"border-t bg-default-50/70 font-medium [&>tr]:last:border-b-0",
className,
)}
data-slot="table-footer"
{...props}
/>
@@ -43,7 +50,10 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
className={cn("border-b transition-colors hover:bg-default-50/50", className)}
className={cn(
"border-b transition-colors hover:bg-default-50/50",
className,
)}
data-slot="table-row"
{...props}
/>
@@ -66,16 +76,26 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0",
className,
)}
data-slot="table-cell"
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption className={cn("mt-4 text-sm text-default-500", className)} data-slot="table-caption" {...props} />
<caption
className={cn("mt-4 text-sm text-default-500", className)}
data-slot="table-caption"
{...props}
/>
);
}
+21 -5
View File
@@ -3,13 +3,23 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root className={cn("flex flex-col gap-2", className)} data-slot="tabs" {...props} />
<TabsPrimitive.Root
className={cn("flex flex-col gap-2", className)}
data-slot="tabs"
{...props}
/>
);
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn(
@@ -22,7 +32,10 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
@@ -35,7 +48,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
className={cn("outline-none", className)}
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { siteConfig } from "@/config/site";
import {
UPDATE_CHANNEL_CHANGED_EVENT,
type UpdateReleaseChannel,
getLatestVersionByChannel,
getUpdateReleaseChannel,
hasVersionUpdate,
} from "@/utils/version-update";
const FALLBACK_GITHUB_REPO = "https://github.com/Sagit-chu/flux-panel";
interface VersionFooterProps {
version: string;
containerClassName?: string;
versionClassName?: string;
poweredClassName?: string;
updateBadgeClassName?: string;
}
export function VersionFooter({
version,
containerClassName,
versionClassName,
poweredClassName,
updateBadgeClassName,
}: VersionFooterProps) {
const [channel, setChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestUpdateVersion, setLatestUpdateVersion] = useState<string | null>(
null,
);
useEffect(() => {
const handleChannelChange = () => {
setChannel(getUpdateReleaseChannel());
};
window.addEventListener(UPDATE_CHANNEL_CHANGED_EVENT, handleChannelChange);
window.addEventListener("storage", handleChannelChange);
return () => {
window.removeEventListener(
UPDATE_CHANNEL_CHANGED_EVENT,
handleChannelChange,
);
window.removeEventListener("storage", handleChannelChange);
};
}, []);
useEffect(() => {
let active = true;
const checkUpdate = async () => {
const latestVersion = await getLatestVersionByChannel(
channel,
siteConfig.github_repo || FALLBACK_GITHUB_REPO,
);
if (!active) {
return;
}
if (!latestVersion) {
setUpdateAvailable(false);
setLatestUpdateVersion(null);
return;
}
const hasUpdate = hasVersionUpdate(version, latestVersion);
setUpdateAvailable(hasUpdate);
setLatestUpdateVersion(hasUpdate ? latestVersion : null);
};
void checkUpdate();
return () => {
active = false;
};
}, [channel, version]);
return (
<div className={containerClassName}>
<p className={versionClassName}>
v{version}
{updateAvailable && latestUpdateVersion && (
<span className={updateBadgeClassName} role="status">
{latestUpdateVersion}
</span>
)}
</p>
<p className={poweredClassName}>
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
</div>
);
}
+146 -13
View File
@@ -6,6 +6,8 @@ export type SiteConfig = typeof siteConfig;
const CACHE_PREFIX = "vite_config_";
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
const APP_VERSION = "1.0.3";
const DEFAULT_FAVICON = "/favicon.ico";
const FAVICON_LINK_ID = "app-favicon";
const GITHUB_REPO =
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
@@ -16,10 +18,15 @@ const getInitialConfig = () => {
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
app_logo: "",
app_favicon: "",
};
}
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
const cachedAppFavicon =
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
if (cachedAppName) {
return {
@@ -27,6 +34,8 @@ const getInitialConfig = () => {
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
};
}
@@ -34,6 +43,9 @@ const getInitialConfig = () => {
name: "FLVX",
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
app_logo: cachedAppLogo,
app_favicon: cachedAppFavicon,
};
};
@@ -85,7 +97,11 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
const response = await getConfigByName(key);
if (response.code === 0 && response.data?.value) {
if (
response.code === 0 &&
response.data &&
typeof response.data.value === "string"
) {
const value = response.data.value;
configCache.set(key, value);
@@ -99,7 +115,7 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
// 获取所有配置(优先从缓存)
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
// 尝试从缓存获取所有配置
const configKeys = ["app_name"];
const configKeys = ["app_name", "app_logo", "app_favicon"];
const cachedConfigs: Record<string, string> = {};
let hasCachedData = false;
@@ -112,6 +128,33 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
}
});
const fetchPublicConfigs = async (): Promise<Record<string, string>> => {
const publicConfigMap: Record<string, string> = {};
await Promise.all(
configKeys.map(async (key) => {
try {
const response = await getConfigByName(key);
if (
response.code === 0 &&
response.data &&
typeof response.data.value === "string"
) {
const value = response.data.value;
publicConfigMap[key] = value;
configCache.set(key, value);
}
} catch {
// ignore single key fetch error
}
}),
);
return publicConfigMap;
};
// 从API获取最新配置
try {
const response = await getConfigs();
@@ -126,31 +169,116 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
return configs;
}
if (hasCachedData) {
return cachedConfigs;
}
return await fetchPublicConfigs();
} catch {
// API失败时返回缓存的数据
if (hasCachedData) {
return cachedConfigs;
}
return await fetchPublicConfigs();
}
};
const updateDocumentFavicon = (faviconUrl: string) => {
if (typeof document === "undefined") {
return;
}
return {};
const normalized = faviconUrl.trim() || DEFAULT_FAVICON;
let iconLink = document.head.querySelector<HTMLLinkElement>(
`link#${FAVICON_LINK_ID}`,
);
if (!iconLink) {
iconLink = document.createElement("link");
iconLink.id = FAVICON_LINK_ID;
iconLink.rel = "icon";
document.head.appendChild(iconLink);
}
iconLink.rel = "icon";
iconLink.href = normalized;
if (normalized.startsWith("data:image/png")) {
iconLink.type = "image/png";
} else {
iconLink.removeAttribute("type");
}
let shortcutIconLink = document.head.querySelector<HTMLLinkElement>(
'link[rel="shortcut icon"]',
);
if (!shortcutIconLink) {
shortcutIconLink = document.createElement("link");
shortcutIconLink.rel = "shortcut icon";
document.head.appendChild(shortcutIconLink);
}
shortcutIconLink.href = normalized;
if (normalized.startsWith("data:image/png")) {
shortcutIconLink.type = "image/png";
} else {
shortcutIconLink.removeAttribute("type");
}
const duplicatedIcons = Array.from(
document.head.querySelectorAll<HTMLLinkElement>('link[rel="icon"]'),
).filter((link) => link !== iconLink);
duplicatedIcons.forEach((link) => link.remove());
};
// 动态更新网站配置
export const updateSiteConfig = async () => {
const appName = await getCachedConfig("app_name");
export const updateSiteConfig = async (configMap?: Record<string, string>) => {
const resolvedConfigMap = configMap ?? (await getCachedConfigs());
Object.entries(resolvedConfigMap).forEach(([key, value]) => {
configCache.set(key, String(value));
});
const hasAppName = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"app_name",
);
const hasAppLogo = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"app_logo",
);
const hasAppFavicon = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"app_favicon",
);
const appName = hasAppName
? String(resolvedConfigMap.app_name || "").trim()
: siteConfig.name;
const appLogo = hasAppLogo
? String(resolvedConfigMap.app_logo || "").trim()
: (siteConfig.app_logo || "").trim();
const appFavicon = hasAppFavicon
? String(resolvedConfigMap.app_favicon || "").trim()
: (siteConfig.app_favicon || "").trim();
if (appName && appName !== siteConfig.name) {
siteConfig.name = appName;
// 更新页面标题
document.title = appName;
}
siteConfig.app_logo = appLogo;
siteConfig.app_favicon = appFavicon;
if (typeof document !== "undefined") {
document.title = siteConfig.name;
}
updateDocumentFavicon(siteConfig.app_favicon);
};
// 清除配置缓存的工具函数
// 缓存清除时机:
// 1. 配置更新时:调用此函数清除所有缓存
// 2. 退出登录时:safeLogout()中的localStorage.clear()会自动清除
// 清除配置缓存的工具函数(用于需要强制重拉配置的场景)
export const clearConfigCache = (keys?: string[]) => {
if (keys && keys.length > 0) {
// 删除指定的配置缓存
@@ -163,8 +291,13 @@ export const clearConfigCache = (keys?: string[]) => {
// 在页面加载时异步更新配置(如果有更新的话)
if (typeof window !== "undefined") {
if (typeof document !== "undefined") {
document.title = siteConfig.name;
}
updateDocumentFavicon(siteConfig.app_favicon);
// 延迟执行,避免阻塞初始渲染
setTimeout(() => {
updateSiteConfig();
}, 200);
void updateSiteConfig();
}, 50);
}
@@ -0,0 +1,51 @@
import { useCallback, useState, type SetStateAction } from "react";
const readStoredValue = <T>(key: string, fallbackValue: T): T => {
try {
const rawValue = localStorage.getItem(key);
if (rawValue === null) {
return fallbackValue;
}
return JSON.parse(rawValue) as T;
} catch {
return fallbackValue;
}
};
export const useLocalStorageState = <T>(
key: string,
initialValue: T,
): readonly [T, (value: SetStateAction<T>) => void, () => void] => {
const [value, setValue] = useState<T>(() =>
readStoredValue(key, initialValue),
);
const setPersistedValue = useCallback(
(nextValue: SetStateAction<T>) => {
setValue((prevValue) => {
const resolvedValue =
typeof nextValue === "function"
? (nextValue as (value: T) => T)(prevValue)
: nextValue;
try {
localStorage.setItem(key, JSON.stringify(resolvedValue));
} catch {}
return resolvedValue;
});
},
[key],
);
const resetPersistedValue = useCallback(() => {
setValue(initialValue);
try {
localStorage.removeItem(key);
} catch {}
}, [initialValue, key]);
return [value, setPersistedValue, resetPersistedValue] as const;
};
+132 -41
View File
@@ -1,5 +1,8 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { toast } from "react-hot-toast";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Dropdown,
@@ -16,9 +19,8 @@ import {
useDisclosure,
} from "@/shadcn-bridge/heroui/modal";
import { Input } from "@/shadcn-bridge/heroui/input";
import { toast } from "react-hot-toast";
import { Logo } from "@/components/icons";
import { BrandLogo } from "@/components/brand-logo";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { siteConfig } from "@/config/site";
@@ -49,6 +51,9 @@ export default function AdminLayout({
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(
() => localStorage.getItem("sidebar_collapsed") === "true",
);
const [username, setUsername] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
@@ -203,6 +208,14 @@ export default function AdminLayout({
setMobileMenuVisible(false);
};
// 切换折叠状态
const toggleCollapse = () => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
};
// 菜单点击处理
const handleMenuClick = (path: string) => {
navigate(path);
@@ -303,74 +316,143 @@ export default function AdminLayout({
className={`
${isMobile ? "fixed" : "relative"}
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
${isMobile ? "w-64" : "w-72"}
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-72"}
bg-white dark:bg-black
shadow-lg
border-r border-gray-200 dark:border-gray-600
z-50
transition-transform duration-300 ease-in-out
transition-all duration-300 ease-in-out
flex flex-col
${isMobile ? "h-screen" : "h-full"}
${isMobile ? "top-0 left-0" : ""}
`}
>
{/* Logo 区域 */}
<div className="px-3 py-3 h-14 flex items-center">
<div className="flex items-center gap-2 w-full">
<Logo size={24} />
<div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">
{siteConfig.name}
</h1>
<p className="text-xs text-default-500">v{siteConfig.version}</p>
</div>
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
<div className="flex-shrink-0 flex items-center justify-center w-10">
<BrandLogo size={28} />
</div>
<div
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
>
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
{siteConfig.name}
</h1>
</div>
</div>
{/* 菜单导航 */}
<nav className="flex-1 px-4 py-6 overflow-y-auto">
<nav className="flex-1 px-3 py-6 overflow-y-auto overflow-x-hidden">
<ul className="space-y-1">
{filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<li key={item.path}>
<button
<motion.button
className={`
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
transition-colors duration-200 min-h-[44px]
w-full flex items-center p-2 rounded-lg text-left
relative min-h-[44px] overflow-hidden transition-colors
${
isActive
? "bg-primary-100 dark:bg-primary-600/20 text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
? "text-primary-600 dark:text-primary-300"
: "text-gray-700 dark:text-gray-200"
}
`}
title={isCollapsed ? item.label : undefined}
transition={{ duration: 0.15 }}
onClick={() => handleMenuClick(item.path)}
>
<div className="flex-shrink-0">{item.icon}</div>
<span className="font-medium text-sm">{item.label}</span>
</button>
{isActive && (
<motion.div
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
layoutId="sidebar-active"
transition={{
type: "spring",
stiffness: 380,
damping: 30,
}}
/>
)}
{!isActive && (
<motion.div
className="absolute inset-0 rounded-lg bg-gray-100 dark:bg-gray-900 opacity-0"
transition={{ duration: 0.15 }}
whileHover={{ opacity: 1 }}
/>
)}
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
{item.icon}
</div>
<div
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}
>
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
{item.label}
</span>
</div>
</motion.button>
</li>
);
})}
</ul>
</nav>
{/* 底部版权信息 */}
<div className="px-4 py-2 pb-4 mt-auto flex-shrink-0">
<div className="text-center">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
{/* 底部版权信息和折叠按钮 */}
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
<div
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
>
<VersionFooter
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={siteConfig.version}
versionClassName="text-xs text-gray-400 dark:text-gray-500"
/>
</div>
{/* 桌面端折叠按钮 */}
{!isMobile && (
<Button
isIconOnly
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
size="sm"
variant="light"
onPress={toggleCollapse}
>
{isCollapsed ? (
// 向右扩展的提示
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 5l7 7-7 7M5 5l7 7-7 7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
) : (
// 向左收起的提示
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
)}
</Button>
)}
</div>
</aside>
@@ -475,10 +557,19 @@ export default function AdminLayout({
</header>
{/* 主内容 */}
<main
className={`flex-1 bg-gray-100 dark:bg-black ${isMobile ? "" : "overflow-y-auto"}`}
>
{children}
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
animate={{ opacity: 1, y: 0 }}
className="h-full"
exit={{ opacity: 0, y: -6 }}
initial={{ opacity: 0, y: 10 }}
transition={{ duration: 0.22, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{children}
</motion.div>
</AnimatePresence>
</main>
</div>
+3 -3
View File
@@ -1,8 +1,8 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Logo } from "@/components/icons";
import { Button } from "@/shadcn-bridge/heroui/button";
import { BrandLogo } from "@/components/brand-logo";
import { siteConfig } from "@/config/site";
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
@@ -33,7 +33,7 @@ export default function H5SimpleLayout({
/>
</svg>
</Button>
<Logo size={20} />
<BrandLogo size={20} />
<h1 className="text-sm font-bold text-foreground">
{siteConfig.name}
</h1>
+5 -5
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Logo } from "@/components/icons";
import { BrandLogo } from "@/components/brand-logo";
import { siteConfig } from "@/config/site";
import { getAdminFlag } from "@/utils/session";
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
@@ -102,7 +102,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
{/* 顶部导航栏 */}
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
<div className="flex items-center gap-2">
<Logo size={20} />
<BrandLogo size={20} />
<h1 className="text-sm font-bold text-foreground">
{siteConfig.name}
</h1>
@@ -115,10 +115,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
<div aria-hidden className="h-16 safe-bottom" />
<div aria-hidden className="h-[calc(4rem+var(--safe-area-bottom))]" />
{/* 底部Tabbar */}
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
{filteredTabItems.map((item) => {
const isActive = location.pathname === item.path;
@@ -126,7 +126,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
<button
key={item.path}
className={`
flex flex-col items-center justify-center flex-1 h-full
flex flex-col items-center justify-center flex-1 h-full pb-[var(--safe-area-bottom)]
transition-colors duration-200 min-h-[44px]
${
isActive
+3
View File
@@ -1,10 +1,13 @@
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { registerSW } from "virtual:pwa-register";
import App from "./App.tsx";
import { Provider } from "./provider.tsx";
import "@/styles/globals.css";
registerSW({ immediate: true });
ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<Provider>
+3 -3
View File
@@ -1,10 +1,10 @@
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { title } from "@/components/primitives";
import { updatePassword } from "@/api";
import DefaultLayout from "@/layouts/default";
+333 -28
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -16,8 +18,6 @@ import {
ModalFooter,
ModalHeader,
} from "@/shadcn-bridge/heroui/modal";
import toast from "react-hot-toast";
import {
updateConfigs,
exportBackup,
@@ -30,9 +30,19 @@ import { SettingsIcon } from "@/components/icons";
import { isAdmin } from "@/utils/auth";
import {
getCachedConfigs,
clearConfigCache,
configCache,
updateSiteConfig,
} from "@/config/site";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
import {
convertBrandAssetToPngDataURL,
isPngDataURL,
type BrandAssetKind,
} from "@/utils/brand-asset";
// 简单的保存图标组件
const SaveIcon = ({ className }: { className?: string }) => (
@@ -62,6 +72,19 @@ interface ConfigItem {
dependsValue?: string; // 依赖的配置项值
}
const BRAND_PREVIEW_KEYS = ["app_logo", "app_favicon"] as const;
type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number];
const isBrandPreviewKey = (key: string): key is BrandPreviewKey =>
BRAND_PREVIEW_KEYS.includes(key as BrandPreviewKey);
const BRAND_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/svg+xml";
const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {
return key === "app_logo" ? "logo" : "favicon";
};
// 网站配置项定义
const CONFIG_ITEMS: ConfigItem[] = [
{
@@ -86,6 +109,20 @@ const CONFIG_ITEMS: ConfigItem[] = [
description: "在浏览器标签页和导航栏显示的应用名称",
type: "input",
},
{
key: "app_logo",
label: "网页角标 Logo",
description:
"用于页面左上角导航角标,上传后会自动转换为 PNG 并持久化保存",
type: "input",
},
{
key: "app_favicon",
label: "浏览器缩略图标",
description:
"用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
type: "input",
},
{
key: "captcha_enabled",
label: "启用验证码",
@@ -138,6 +175,8 @@ const getInitialConfigs = (): Record<string, string> => {
"cloudflare_secret_key",
"ip",
"panel_domain",
"app_logo",
"app_favicon",
];
const initialConfigs: Record<string, string> = {};
@@ -174,7 +213,9 @@ export default function ConfigPage() {
const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
const [importFileName, setImportFileName] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const backupFileInputRef = useRef<HTMLInputElement>(null);
const logoFileInputRef = useRef<HTMLInputElement>(null);
const faviconFileInputRef = useRef<HTMLInputElement>(null);
const [announcement, setAnnouncement] = useState<AnnouncementData>({
content: "",
@@ -182,6 +223,15 @@ export default function ConfigPage() {
});
const [announcementLoading, setAnnouncementLoading] = useState(true);
const [announcementSaving, setAnnouncementSaving] = useState(false);
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const [previewLoadFailed, setPreviewLoadFailed] = useState<
Partial<Record<BrandPreviewKey, boolean>>
>({});
const [brandUploading, setBrandUploading] = useState<
Partial<Record<BrandPreviewKey, boolean>>
>({});
// 权限检查
useEffect(() => {
@@ -267,11 +317,23 @@ export default function ConfigPage() {
}
};
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value };
setConfigs(newConfigs);
if (isBrandPreviewKey(key)) {
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
}
const hasChangesNow =
Object.keys(newConfigs).some(
(k) => newConfigs[k] !== originalConfigs[k],
@@ -287,25 +349,40 @@ export default function ConfigPage() {
const handleSave = async () => {
setSaving(true);
try {
const response = await updateConfigs(configs);
const changedKeys = Object.keys(configs).filter(
(key) => configs[key] !== originalConfigs[key],
);
if (changedKeys.length === 0) {
setHasChanges(false);
return;
}
const changedPayload: Record<string, string> = {};
changedKeys.forEach((key) => {
changedPayload[key] = configs[key] || "";
});
const response = await updateConfigs(changedPayload);
if (response.code === 0) {
toast.success("配置保存成功");
// 清除所有配置缓存,强制下次重新获取
clearConfigCache();
// 获取变更的配置项
const changedKeys = Object.keys(configs).filter(
(key) => configs[key] !== originalConfigs[key],
);
Object.entries(configs).forEach(([key, value]) => {
configCache.set(key, value);
});
setOriginalConfigs({ ...configs });
setHasChanges(false);
// 如果应用名称发生变化,立即更新网站配置
if (changedKeys.includes("app_name")) {
await updateSiteConfig();
if (
changedKeys.some((key) =>
["app_name", "app_logo", "app_favicon"].includes(key),
)
) {
await updateSiteConfig(configs);
}
// 触发配置更新事件,通知其他组件
@@ -333,6 +410,191 @@ export default function ConfigPage() {
return configs[item.dependsOn] === item.dependsValue;
};
const getBrandInputRef = (key: BrandPreviewKey) => {
return key === "app_logo" ? logoFileInputRef : faviconFileInputRef;
};
const triggerBrandFilePicker = (key: BrandPreviewKey) => {
if (brandUploading[key]) {
return;
}
getBrandInputRef(key).current?.click();
};
const clearBrandAsset = (key: BrandPreviewKey) => {
handleConfigChange(key, "");
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
};
const handleBrandFileChange = async (
key: BrandPreviewKey,
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setBrandUploading((prev) => ({ ...prev, [key]: true }));
try {
const pngDataURL = await convertBrandAssetToPngDataURL(
file,
toBrandAssetKind(key),
);
handleConfigChange(key, pngDataURL);
toast.success(key === "app_logo" ? "Logo 上传成功" : "Favicon 上传成功");
} catch (error) {
const message =
error instanceof Error ? error.message : "图片处理失败,请重试";
toast.error(message);
} finally {
setBrandUploading((prev) => ({ ...prev, [key]: false }));
event.target.value = "";
}
};
const renderBrandPreview = (key: BrandPreviewKey) => {
const previewUrl = (configs[key] || "").trim();
const appNamePreview = (configs.app_name || "").trim() || "应用名称";
const failed = previewLoadFailed[key] === true;
const showImage = previewUrl.length > 0 && !failed;
return (
<div className="mt-3 rounded-lg border border-default-200 dark:border-default-100/30 bg-default-50/60 dark:bg-default-100/10 p-3">
<p className="text-xs text-default-500">实时预览</p>
<div className="mt-2 rounded-md border border-default-200 dark:border-default-100/30 bg-white dark:bg-black px-3 py-2">
{key === "app_logo" ? (
<div className="flex h-10 items-center gap-2">
{showImage ? (
<img
alt="logo preview"
className="h-7 w-7 rounded-sm border border-default-200 object-cover dark:border-default-100/30"
src={previewUrl}
onError={() =>
setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))
}
onLoad={() =>
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))
}
/>
) : (
<div className="flex h-7 w-7 items-center justify-center rounded-sm bg-default-200 text-[10px] font-semibold text-default-600 dark:bg-default-700 dark:text-default-200">
LOGO
</div>
)}
<span className="truncate text-sm font-semibold text-foreground">
{appNamePreview}
</span>
</div>
) : (
<div className="flex h-7 max-w-[260px] items-center gap-2 rounded border border-default-200 bg-default-100/70 px-2 dark:border-default-100/30 dark:bg-default-100/20">
{showImage ? (
<img
alt="favicon preview"
className="h-4 w-4 rounded-sm object-contain"
src={previewUrl}
onError={() =>
setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))
}
onLoad={() =>
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))
}
/>
) : (
<div className="h-4 w-4 rounded-sm bg-default-300 dark:bg-default-600" />
)}
<span className="truncate text-xs text-default-700 dark:text-default-300">
{appNamePreview}
</span>
</div>
)}
</div>
{previewUrl.length === 0 ? (
<p className="mt-2 text-xs text-default-500">
上传图片后会实时显示预览
</p>
) : null}
{previewUrl.length > 0 && failed ? (
<p className="mt-2 text-xs text-danger">图片加载失败,请重新上传</p>
) : null}
{previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (
<p className="mt-2 text-xs text-warning-600 dark:text-warning-400">
当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载
</p>
) : null}
</div>
);
};
const renderBrandAssetUploader = (key: BrandPreviewKey, isChanged: boolean) => {
const value = (configs[key] || "").trim();
const uploading = brandUploading[key] === true;
const isLogo = key === "app_logo";
return (
<div
className={`rounded-lg border p-3 ${
isChanged
? "border-warning-300"
: "border-default-200 dark:border-default-100/30"
}`}
>
<input
accept={BRAND_FILE_ACCEPT}
className="hidden"
ref={getBrandInputRef(key)}
type="file"
onChange={(event) => {
void handleBrandFileChange(key, event);
}}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
color="primary"
isLoading={uploading}
size="sm"
variant="flat"
onPress={() => triggerBrandFilePicker(key)}
>
{value.length > 0
? isLogo
? "替换 Logo"
: "替换 Favicon"
: isLogo
? "上传 Logo"
: "上传 Favicon"}
</Button>
<Button
isDisabled={value.length === 0 || uploading}
size="sm"
variant="light"
onPress={() => clearBrandAsset(key)}
>
清除
</Button>
<span className="text-xs text-default-500">仅支持图片文件,自动转换为 PNG</span>
</div>
<p className="mt-2 text-xs text-default-500">
{isLogo
? "建议上传方形图片,系统会统一转换为 96x96 PNG"
: "建议上传方形图片,系统会统一转换为 64x64 PNG"}
</p>
{renderBrandPreview(key)}
</div>
);
};
// 渲染不同类型的配置项
const renderConfigItem = (item: ConfigItem) => {
const isChanged =
@@ -340,6 +602,10 @@ export default function ConfigPage() {
switch (item.type) {
case "input":
if (isBrandPreviewKey(item.key)) {
return renderBrandAssetUploader(item.key, isChanged);
}
return (
<Input
classNames={{
@@ -434,7 +700,7 @@ export default function ConfigPage() {
}
setImportSelectorOpen(false);
requestAnimationFrame(() => fileInputRef.current?.click());
requestAnimationFrame(() => backupFileInputRef.current?.click());
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -471,8 +737,8 @@ export default function ConfigPage() {
toast.error("导入失败,请检查文件格式");
} finally {
setImporting(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
if (backupFileInputRef.current) {
backupFileInputRef.current.value = "";
}
}
};
@@ -585,7 +851,7 @@ export default function ConfigPage() {
</div>
<Card className="shadow-md">
<CardHeader className="pb-4">
<CardHeader className="pb-6">
<div className="flex justify-between items-center w-full">
<div>
<h2 className="text-xl font-semibold">基本设置</h2>
@@ -609,7 +875,7 @@ export default function ConfigPage() {
<Divider />
<CardBody className="space-y-6 pt-6">
<CardBody className="space-y-6 pt-8 md:pt-8">
{CONFIG_ITEMS.map((item, index) => {
// 检查配置项是否应该显示
if (!shouldShowItem(item)) {
@@ -643,15 +909,51 @@ export default function ConfigPage() {
</div>
);
})}
<Divider className="my-2" />
<div className="space-y-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
更新通道
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
的版本。
</p>
</div>
<Select
selectedKeys={[updateChannel]}
size="md"
variant="bordered"
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
稳定版
</SelectItem>
<SelectItem
key="dev"
description="仅 alpha / beta / rc 关键字版本"
>
开发版
</SelectItem>
</Select>
</div>
</CardBody>
</Card>
{hasChanges && (
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<CardBody className="py-3">
<div className="flex items-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
<span className="text-sm">
<span className="text-sm font-medium">
检测到配置变更,请记得保存您的修改
</span>
</div>
@@ -660,7 +962,7 @@ export default function ConfigPage() {
)}
<Card className="mt-6 shadow-md">
<CardHeader className="pb-4">
<CardHeader className="pb-6">
<div className="flex justify-between items-center w-full">
<div>
<h2 className="text-xl font-semibold">公告管理</h2>
@@ -673,7 +975,7 @@ export default function ConfigPage() {
<Divider />
<CardBody className="space-y-4 pt-6">
<CardBody className="space-y-4 pt-8 md:pt-8">
{announcementLoading ? (
<div className="flex justify-center py-8">
<Spinner size="lg" />
@@ -702,13 +1004,16 @@ export default function ConfigPage() {
<Textarea
label="公告内容"
minRows={4}
placeholder="请输入公告内容"
placeholder="支持 Markdown,例如:**加粗**、[链接](https://example.com)、- 列表"
value={announcement.content}
variant="bordered"
onChange={(e) =>
setAnnouncement({ ...announcement, content: e.target.value })
}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
公告支持 Markdown 语法,链接会在新标签页打开
</p>
<div className="flex justify-end">
<Button
@@ -727,7 +1032,7 @@ export default function ConfigPage() {
{/* 备份与恢复 */}
<Card className="mt-6 shadow-md">
<CardHeader className="pb-4">
<CardHeader className="pb-6">
<div className="flex justify-between items-center w-full">
<div>
<h2 className="text-xl font-semibold">数据备份与恢复</h2>
@@ -740,7 +1045,7 @@ export default function ConfigPage() {
<Divider />
<CardBody className="space-y-6 pt-6">
<CardBody className="space-y-6 pt-8 md:pt-8">
{/* 导出部分 */}
<div className="space-y-4">
<h3 className="text-lg font-medium">导出数据</h3>
@@ -775,7 +1080,7 @@ export default function ConfigPage() {
</p>
<input
ref={fileInputRef}
ref={backupFileInputRef}
accept=".json"
className="hidden"
type="file"
+11 -5
View File
@@ -1,9 +1,15 @@
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Modal, ModalContent, ModalHeader, ModalBody } from "@/shadcn-bridge/heroui/modal";
import { useState } from "react";
import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
} from "@/shadcn-bridge/heroui/modal";
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
@@ -519,7 +525,7 @@ export default function DashboardPage() {
}
return (
<div className="px-3 lg:px-6 py-2 lg:py-4">
<AnimatedPage className="px-3 lg:px-6 py-2 lg:py-4">
{announcement && <AnnouncementBanner announcement={announcement} />}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
<MetricCard
@@ -973,6 +979,6 @@ export default function DashboardPage() {
</ModalBody>
</ModalContent>
</Modal>
</div>
</AnimatedPage>
);
}
@@ -1,5 +1,9 @@
import type { AnnouncementData } from "@/api";
import ReactMarkdown from "react-markdown";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
interface AnnouncementBannerProps {
@@ -14,13 +18,13 @@ export const AnnouncementBanner = ({
}
return (
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
<CardBody className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
<Card className="mb-4 lg:mb-6 border border-blue-100 dark:border-blue-500/20 bg-gradient-to-br from-blue-50/80 to-indigo-50/80 dark:from-blue-500/5 dark:to-indigo-500/5 shadow-sm">
<CardBody className="p-4 sm:p-5">
<div className="flex items-center gap-3.5 sm:gap-4">
<div className="w-10 h-10 sm:w-11 sm:h-11 bg-white dark:bg-blue-500/10 rounded-full flex-shrink-0 flex items-center justify-center shadow-sm border border-blue-100 dark:border-blue-500/20">
<svg
aria-hidden="true"
className="w-5 h-5 text-blue-600 dark:text-blue-400"
className="w-5 h-5 sm:w-5 sm:h-5 text-blue-500 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
@@ -32,12 +36,57 @@ export const AnnouncementBanner = ({
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
<h3 className="text-[15px] font-semibold text-blue-900 dark:text-blue-100 tracking-wide mb-1 sm:mb-1.5 mt-1.5">
公告
</h3>
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
{announcement.content}
</p>
<div className="text-sm text-blue-800/90 dark:text-blue-100/90 break-words leading-relaxed">
<ReactMarkdown
components={{
p: ({ children }) => (
<p className="mb-2 last:mb-0">{children}</p>
),
a: ({ children, href }) => (
<a
className="underline decoration-blue-500/70 underline-offset-2 hover:text-blue-700 dark:hover:text-blue-100"
href={href}
rel="noopener noreferrer"
target="_blank"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1 mb-2 last:mb-0">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1 mb-2 last:mb-0">
{children}
</ol>
),
code: ({ children }) => (
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mb-2 overflow-x-auto rounded-md bg-blue-100/70 dark:bg-blue-900/40 p-2.5 text-xs leading-relaxed">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="mb-2 border-l-2 border-blue-300/80 dark:border-blue-500/60 pl-3 italic">
{children}
</blockquote>
),
}}
rehypePlugins={[rehypeSanitize]}
remarkPlugins={[remarkGfm]}
>
{announcement.content}
</ReactMarkdown>
</div>
</div>
</div>
</CardBody>
@@ -1,4 +1,3 @@
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import {
CartesianGrid,
Line,
@@ -9,6 +8,7 @@ import {
YAxis,
} from "recharts";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { PageEmptyState } from "@/components/page-state";
interface FlowChartPoint {
@@ -1,3 +1,5 @@
import type { ForwardApiItem } from "@/api/types";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -6,7 +8,6 @@ import {
getUserPackageInfo,
type AnnouncementData,
} from "@/api";
import type { ForwardApiItem } from "@/api/types";
import { getAdminFlag } from "@/utils/session";
export interface DashboardUserInfo {
@@ -180,9 +181,13 @@ export const useDashboardData = (): DashboardDataState => {
);
const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]);
const [forwardList, setForwardList] = useState<DashboardForward[]>([]);
const [statisticsFlows, setStatisticsFlows] = useState<DashboardStatisticsFlow[]>([]);
const [statisticsFlows, setStatisticsFlows] = useState<
DashboardStatisticsFlow[]
>([]);
const [isAdmin, setIsAdmin] = useState(false);
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
null,
);
useEffect(() => {
const loadAnnouncement = async () => {
@@ -214,7 +219,10 @@ export const useDashboardData = (): DashboardDataState => {
setForwardList(normalizedForwards);
setStatisticsFlows(data.statisticsFlows || []);
checkExpirationNotifications(data.userInfo, normalizedTunnelPermissions);
checkExpirationNotifications(
data.userInfo,
normalizedTunnelPermissions,
);
} else {
toast.error(res.msg || "获取套餐信息失败");
}
File diff suppressed because it is too large Load Diff
+37 -15
View File
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -21,8 +23,6 @@ import {
} from "@/shadcn-bridge/heroui/table";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import toast from "react-hot-toast";
import {
assignGroupPermission,
assignTunnelsToGroup,
@@ -173,6 +173,22 @@ export default function GroupPage() {
return map;
}, [users]);
const selectedTunnelSummary = useMemo(() => {
const value = Array.from(selectedTunnelKeys)
.map((id) => tunnelNameMap.get(Number(id)) || id)
.join("、");
return value || "无";
}, [selectedTunnelKeys, tunnelNameMap]);
const selectedUserSummary = useMemo(() => {
const value = Array.from(selectedUserKeys)
.map((id) => userNameMap.get(Number(id)) || id)
.join("、");
return value || "无";
}, [selectedUserKeys, userNameMap]);
const loadData = useCallback(async () => {
setLoading(true);
try {
@@ -599,7 +615,7 @@ export default function GroupPage() {
<h3 className="text-lg font-semibold">权限分配</h3>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
<Select
items={userGroups}
label="用户分组"
@@ -629,8 +645,10 @@ export default function GroupPage() {
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Button
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
color="primary"
isLoading={savingPermission}
size="sm"
onPress={handleAssignPermission}
>
分配权限
@@ -764,8 +782,10 @@ export default function GroupPage() {
>
<ModalContent>
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
<ModalBody>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={tunnels}
label="选择隧道"
selectedKeys={selectedTunnelKeys}
@@ -778,11 +798,11 @@ export default function GroupPage() {
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<p className="text-xs text-default-500">
当前已选:
{Array.from(selectedTunnelKeys)
.map((id) => tunnelNameMap.get(Number(id)) || id)
.join("、") || "无"}
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedTunnelSummary}`}
>
当前已选:{selectedTunnelSummary}
</p>
<p className="text-xs text-default-500">
不选择任何隧道并保存将清空该分组成员。
@@ -809,8 +829,10 @@ export default function GroupPage() {
>
<ModalContent>
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
<ModalBody>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={users}
label="选择用户"
selectedKeys={selectedUserKeys}
@@ -823,11 +845,11 @@ export default function GroupPage() {
>
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
</Select>
<p className="text-xs text-default-500">
当前已选:
{Array.from(selectedUserKeys)
.map((id) => userNameMap.get(Number(id)) || id)
.join("、") || "无"}
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedUserSummary}`}
>
当前已选:{selectedUserSummary}
</p>
<p className="text-xs text-default-500">
不选择任何用户并保存将清空该分组成员。
+19 -21
View File
@@ -1,13 +1,15 @@
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Turnstile } from "@marsidev/react-turnstile";
import { motion } from "framer-motion";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site";
import { title } from "@/components/primitives";
import { VersionFooter } from "@/components/version-footer";
import DefaultLayout from "@/layouts/default";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
import { writeLoginSession } from "@/utils/session";
@@ -152,7 +154,12 @@ export default function IndexPage() {
return (
<DefaultLayout>
<section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 pb-20 min-h-[calc(100dvh-120px)] sm:min-h-[calc(100dvh-200px)]">
<div className="w-full max-w-md px-4 sm:px-0">
<motion.div
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md px-4 sm:px-0"
initial={{ opacity: 0, y: 24 }}
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Card className="w-full">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<h1 className={title({ size: "sm" })}>登陆</h1>
@@ -203,26 +210,17 @@ export default function IndexPage() {
</div>
</CardBody>
</Card>
</div>
</motion.div>
{/* 版权信息 - 固定在底部,不占据布局空间 */}
<div className="fixed inset-x-0 bottom-4 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{isWebView ? siteConfig.app_version : siteConfig.version}
</p>
</div>
<VersionFooter
containerClassName="fixed inset-x-0 bottom-4 text-center py-4"
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={isWebView ? siteConfig.app_version : siteConfig.version}
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
/>
{/* 验证码弹层 */}
{showCaptcha && siteKey && (
+187 -178
View File
@@ -1,4 +1,12 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import {
AnimatedPage,
StaggerList,
StaggerItem,
} from "@/components/animated-page";
import { SearchBar } from "@/components/search-bar";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -11,8 +19,6 @@ import {
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import toast from "react-hot-toast";
import {
createSpeedLimit,
getSpeedLimitList,
@@ -21,14 +27,15 @@ import {
getTunnelList,
} from "@/api";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
interface SpeedLimitRule {
id: number;
name: string;
speed: number;
status: number;
tunnelId: number;
tunnelName: string;
tunnelId?: number | null;
tunnelName?: string;
createdTime: string;
updatedTime: string;
}
@@ -51,6 +58,22 @@ export default function LimitPage() {
const [loading, setLoading] = useState(true);
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"limit-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const filteredRules = useMemo(() => {
if (!searchKeyword.trim()) return rules;
const lowerKeyword = searchKeyword.toLowerCase();
return rules.filter(
(r) =>
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword)),
);
}, [rules, searchKeyword]);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
@@ -116,9 +139,7 @@ export default function LimitPage() {
newErrors.speed = "请输入有效的速度限制(≥1 Mbps)";
}
if (!form.tunnelId) {
newErrors.tunnelId = "请选择要绑定的隧道";
}
// tunnelId is optional - speed limits can be created without binding to a tunnel
setErrors(newErrors);
@@ -146,8 +167,8 @@ export default function LimitPage() {
id: rule.id,
name: rule.name,
speed: rule.speed,
tunnelId: rule.tunnelId,
tunnelName: rule.tunnelName,
tunnelId: rule.tunnelId ?? null,
tunnelName: rule.tunnelName ?? "",
status: rule.status,
});
setErrors({});
@@ -196,6 +217,8 @@ export default function LimitPage() {
const createData = { ...form };
delete createData.id;
createData.tunnelId = null;
createData.tunnelName = "";
res = await createSpeedLimit(createData);
}
@@ -219,10 +242,18 @@ export default function LimitPage() {
}
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<AnimatedPage className="px-3 lg:px-6 py-8">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索规则名称或绑定隧道"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
@@ -230,138 +261,117 @@ export default function LimitPage() {
</div>
{/* 统一卡片网格 */}
{rules.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{rules.map((rule) => (
<Card
key={rule.id}
className="shadow-sm border border-gray-200 dark:border-gray-700"
>
<CardHeader className="pb-3">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">
{rule.name}
</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
速度限制
</span>
<Chip color="secondary" size="sm" variant="flat">
{rule.speed} Mbps
{filteredRules.length > 0 ? (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
<StaggerItem key={rule.id}>
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">
{rule.name}
</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</div>
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
绑定隧道
</span>
{rule.tunnelName ? (
<Chip color="primary" size="sm" variant="flat">
{rule.tunnelName}
</Chip>
) : (
<span className="text-default-400 text-small">
未绑定
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
速度限制
</span>
)}
<Chip color="secondary" size="sm" variant="flat">
{rule.speed} Mbps
</Chip>
</div>
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
绑定隧道
</span>
{rule.tunnelName ? (
<Chip color="primary" size="sm" variant="flat">
{rule.tunnelName}
</Chip>
) : (
<span className="text-default-400 text-small">
未绑定
</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
className="flex-1"
color="primary"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="flex-1"
color="danger"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</CardBody>
</Card>
<div className="flex gap-2 mt-4">
<Button
className="flex-1"
color="primary"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="flex-1"
color="danger"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</CardBody>
</Card>
</StaggerItem>
))}
</div>
</StaggerList>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg
aria-hidden="true"
className="w-8 h-8 text-default-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 6v6l4 2m6-6a9 9 0 11-18 0 9 9 0 0118 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无限速规则
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何限速规则,点击上方按钮开始创建
</p>
</div>
</div>
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无限速规则
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何限速规则,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
@@ -383,9 +393,7 @@ export default function LimitPage() {
{isEdit ? "编辑限速规则" : "新增限速规则"}
</h2>
<p className="text-small text-default-500">
{isEdit
? "修改现有限速规则的配置信息"
: "创建新的限速规则并绑定到隧道"}
{isEdit ? "修改现有限速规则的配置信息" : "创建新的限速规则"}
</p>
</ModalHeader>
<ModalBody>
@@ -425,43 +433,44 @@ export default function LimitPage() {
}
/>
<Select
description={isEdit ? "编辑时无法修改绑定隧道" : undefined}
errorMessage={errors.tunnelId}
isDisabled={isEdit}
isInvalid={!!errors.tunnelId}
label="绑定隧道"
placeholder="请选择要绑定的隧道"
selectedKeys={
form.tunnelId ? [form.tunnelId.toString()] : []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
const selectedTunnel = tunnels.find(
(tunnel) => tunnel.id === parseInt(selectedKey),
);
setForm((prev) => ({
...prev,
tunnelId: parseInt(selectedKey),
tunnelName: selectedTunnel?.name || "",
}));
} else {
setForm((prev) => ({
...prev,
tunnelId: null,
tunnelName: "",
}));
{isEdit && (
<Select
description="仅编辑时可调整绑定隧道"
errorMessage={errors.tunnelId}
isInvalid={!!errors.tunnelId}
label="绑定隧道"
placeholder="可选择要绑定的隧道(可选)"
selectedKeys={
form.tunnelId ? [form.tunnelId.toString()] : []
}
}}
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>
))}
</Select>
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string;
if (selectedKey) {
const selectedTunnel = tunnels.find(
(tunnel) => tunnel.id === parseInt(selectedKey),
);
setForm((prev) => ({
...prev,
tunnelId: parseInt(selectedKey),
tunnelName: selectedTunnel?.name || "",
}));
} else {
setForm((prev) => ({
...prev,
tunnelId: null,
tunnelName: "",
}));
}
}}
>
{tunnels.map((tunnel) => (
<SelectItem key={tunnel.id}>{tunnel.name}</SelectItem>
))}
</Select>
)}
</div>
</ModalBody>
<ModalFooter>
@@ -524,6 +533,6 @@ export default function LimitPage() {
)}
</ModalContent>
</Modal>
</div>
</AnimatedPage>
);
}
+271 -99
View File
@@ -1,23 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@/shadcn-bridge/heroui/input";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import toast from "react-hot-toast";
import {
DndContext,
@@ -37,6 +18,27 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Textarea } from "@/shadcn-bridge/heroui/input";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import {
createNode,
getNodeList,
@@ -49,6 +51,7 @@ import {
batchUpgradeNodes,
getNodeReleases,
rollbackNode,
type ReleaseChannel,
} from "@/api";
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import {
@@ -59,6 +62,7 @@ import { tryCopyInstallCommand } from "@/pages/node/install-command";
import { buildNodeSystemInfo } from "@/pages/node/system-info";
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
interface Node {
@@ -126,11 +130,17 @@ const SortableItem = ({
isDragging,
} = useSortable({ id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
const style: React.CSSProperties = {
transform: transform
? CSS.Transform.toString({
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
: undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: "transform",
willChange: isDragging ? "transform" : undefined,
};
return (
@@ -149,6 +159,11 @@ export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"node-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [dialogVisible, setDialogVisible] = useState(false);
const [dialogTitle, setDialogTitle] = useState("");
const [isEdit, setIsEdit] = useState(false);
@@ -183,6 +198,10 @@ export default function NodePage() {
const [installCommandModal, setInstallCommandModal] = useState(false);
const [installCommand, setInstallCommand] = useState("");
const [currentNodeName, setCurrentNodeName] = useState("");
const [installSelectorOpen, setInstallSelectorOpen] = useState(false);
const [installTargetNode, setInstallTargetNode] = useState<Node | null>(null);
const [installChannel, setInstallChannel] =
useState<ReleaseChannel>("stable");
// 升级相关状态
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
@@ -198,9 +217,12 @@ export default function NodePage() {
name: string;
publishedAt: string;
prerelease: boolean;
channel: ReleaseChannel;
}>
>([]);
const [releasesLoading, setReleasesLoading] = useState(false);
const [releaseChannel, setReleaseChannel] =
useState<ReleaseChannel>("stable");
const [selectedVersion, setSelectedVersion] = useState("");
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
const [upgradeProgress, setUpgradeProgress] = useState<
@@ -609,20 +631,31 @@ export default function NodePage() {
}
};
const openInstallSelector = (node: Node) => {
setInstallTargetNode(node);
setInstallChannel("stable");
setInstallSelectorOpen(true);
};
// 复制安装命令
const handleCopyInstallCommand = async (node: Node) => {
const handleCopyInstallCommand = async (
node: Node,
channel: ReleaseChannel,
) => {
setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),
);
try {
const res = await getNodeInstallCommand(node.id);
const res = await getNodeInstallCommand(node.id, channel);
if (res.code === 0 && res.data) {
const copied = await tryCopyInstallCommand(res.data);
if (copied) {
toast.success("安装命令已复制到剪贴板");
toast.success(
`${channel === "stable" ? "正式版" : "测试版"}安装命令已复制到剪贴板`,
);
} else {
setInstallCommand(res.data);
setCurrentNodeName(node.name);
@@ -640,6 +673,13 @@ export default function NodePage() {
}
};
const handleConfirmInstallCommand = async () => {
if (!installTargetNode) return;
setInstallSelectorOpen(false);
await handleCopyInstallCommand(installTargetNode, installChannel);
};
// 手动复制安装命令
const handleManualCopy = async () => {
try {
@@ -651,18 +691,10 @@ export default function NodePage() {
}
};
// 打开版本选择弹窗
const openUpgradeModal = async (
target: "single" | "batch",
nodeId?: number,
) => {
setUpgradeTarget(target);
setUpgradeTargetNodeId(nodeId || null);
setSelectedVersion("");
setUpgradeModalOpen(true);
const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {
setReleasesLoading(true);
try {
const res = await getNodeReleases();
const res = await getNodeReleases(channel);
if (res.code === 0 && Array.isArray(res.data)) {
setReleases(res.data);
@@ -674,6 +706,21 @@ export default function NodePage() {
} finally {
setReleasesLoading(false);
}
}, []);
// 打开版本选择弹窗
const openUpgradeModal = async (
target: "single" | "batch",
nodeId?: number,
) => {
const defaultChannel: ReleaseChannel = "stable";
setUpgradeTarget(target);
setUpgradeTargetNodeId(nodeId || null);
setReleaseChannel(defaultChannel);
setSelectedVersion("");
setUpgradeModalOpen(true);
await loadReleasesByChannel(defaultChannel);
};
// 确认升级(从版本弹窗)
@@ -692,7 +739,11 @@ export default function NodePage() {
),
);
try {
const res = await upgradeNode(upgradeTargetNodeId, version);
const res = await upgradeNode(
upgradeTargetNodeId,
version,
releaseChannel,
);
if (res.code === 0) {
toast.success(`节点升级命令已发送,节点将自动重启`);
@@ -712,7 +763,11 @@ export default function NodePage() {
setBatchUpgradeLoading(true);
setUpgradeModalOpen(false);
try {
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
const res = await batchUpgradeNodes(
Array.from(selectedIds),
version,
releaseChannel,
);
if (res.code === 0) {
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
@@ -953,7 +1008,21 @@ export default function NodePage() {
const sortedNodes = useMemo((): Node[] => {
if (!nodeList || nodeList.length === 0) return [];
const sortedByDb = [...nodeList].sort((a, b) => {
let filteredNodes = nodeList;
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredNodes = filteredNodes.filter(
(n) =>
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
);
}
const sortedByDb = [...filteredNodes].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -966,7 +1035,7 @@ export default function NodePage() {
nodeOrder.length > 0 &&
sortedByDb.every((n) => n.inx === undefined || n.inx === 0)
) {
const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const));
const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));
const localSorted: Node[] = [];
nodeOrder.forEach((id) => {
@@ -975,7 +1044,7 @@ export default function NodePage() {
if (node) localSorted.push(node);
});
nodeList.forEach((node) => {
filteredNodes.forEach((node) => {
if (!nodeOrder.includes(node.id)) {
localSorted.push(node);
}
@@ -985,7 +1054,7 @@ export default function NodePage() {
}
return sortedByDb;
}, [nodeList, nodeOrder]);
}, [nodeList, nodeOrder, searchKeyword]);
const sortableNodeIds = useMemo(
() => sortedNodes.map((n) => n.id),
@@ -993,59 +1062,94 @@ export default function NodePage() {
);
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<div className="flex gap-2 items-center">
<Button
color={selectMode ? "warning" : "default"}
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
{selectMode ? "取消多选" : "多选"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<AnimatedPage className="px-3 lg:px-6 py-8">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索节点名称或IP"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
</div>
</div>
{/* 批量操作浮动工具栏 */}
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm font-medium shrink-0">
已选 {selectedIds.size} 项
</span>
<Button size="sm" variant="flat" onPress={selectAll}>
全选
</Button>
<Button size="sm" variant="flat" onPress={deselectAll}>
清空
</Button>
<Button
color="warning"
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="warning"
isDisabled={selectedIds.size === 0}
isLoading={batchUpgradeLoading}
size="sm"
variant="flat"
onPress={() => openUpgradeModal("batch")}
>
升级
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</div>
</div>
)}
</div>
{!wsConnected && (
<Alert
@@ -1083,7 +1187,7 @@ export default function NodePage() {
key={node.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
>
<CardHeader className="pb-2">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex items-center gap-2 flex-1 min-w-0">
{selectMode && (
@@ -1143,7 +1247,7 @@ export default function NodePage() {
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
{isRemoteNode && node.syncError && (
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{getRemoteSyncErrorMessage(node.syncError)}
@@ -1155,7 +1259,7 @@ export default function NodePage() {
<span className="text-default-600 flex-shrink-0">
IP
</span>
<div className="text-right text-xs min-w-0 flex-1 ml-2">
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
{node.serverIpV4?.trim() ||
node.serverIpV6?.trim() ? (
<div className="space-y-0.5">
@@ -1357,7 +1461,7 @@ export default function NodePage() {
isLoading={node.copyLoading}
size="sm"
variant="flat"
onPress={() => handleCopyInstallCommand(node)}
onPress={() => openInstallSelector(node)}
>
安装
</Button>
@@ -1801,6 +1905,55 @@ export default function NodePage() {
</ModalContent>
</Modal>
<Modal
backdrop="blur"
isOpen={installSelectorOpen}
placement="center"
size="md"
onOpenChange={setInstallSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">
选择安装通道
{installTargetNode ? ` - ${installTargetNode.name}` : ""}
</h2>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<Select
label="版本通道"
selectedKeys={[installChannel]}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as ReleaseChannel;
setInstallChannel(selected || "stable");
}}
>
<SelectItem key="stable" textValue="stable">
正式版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
测试版(含 alpha / beta / rc)
</SelectItem>
</Select>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button color="primary" onPress={handleConfirmInstallCommand}>
生成命令
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 安装命令模态框 */}
<Modal
backdrop="blur"
@@ -1881,9 +2034,28 @@ export default function NodePage() {
</div>
) : (
<div className="space-y-4">
<Select
label="版本通道"
selectedKeys={[releaseChannel]}
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as ReleaseChannel) || "stable";
setReleaseChannel(selected);
setSelectedVersion("");
void loadReleasesByChannel(selected);
}}
>
<SelectItem key="stable" textValue="stable">
正式版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
测试版(含 alpha / beta / rc)
</SelectItem>
</Select>
<Select
label="选择版本"
placeholder="留空则使用最新版本"
placeholder="留空则使用当前通道最新版本"
selectedKeys={selectedVersion ? [selectedVersion] : []}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string;
@@ -1899,14 +2071,14 @@ export default function NodePage() {
{r.publishedAt
? new Date(r.publishedAt).toLocaleDateString()
: ""}
{r.prerelease && (
{r.channel === "dev" && (
<Chip
className="ml-1"
color="warning"
size="sm"
variant="flat"
>
预览
测试
</Chip>
)}
</span>
@@ -1917,7 +2089,7 @@ export default function NodePage() {
<p className="text-sm text-default-500">
{selectedVersion
? `将升级到版本 ${selectedVersion}`
: "未选择版本,将自动使用最新稳定版"}
: `未选择版本,将自动使用最新${releaseChannel === "stable" ? "正式" : "测试"}版`}
</p>
</div>
)}
@@ -1979,6 +2151,6 @@ export default function NodePage() {
)}
</ModalContent>
</Modal>
</div>
</AnimatedPage>
);
}
+101 -20
View File
@@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs";
@@ -11,8 +13,6 @@ import {
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { toast } from "react-hot-toast";
import {
getNodeList,
createPeerShare,
@@ -375,6 +375,9 @@ export default function PanelSharingPage() {
};
const formatChainType = (chainType: number, hopInx: number) => {
if (chainType === 1) {
return "入口节点";
}
if (chainType === 2) {
return `中继跳点 #${hopInx}`;
}
@@ -397,11 +400,31 @@ export default function PanelSharingPage() {
selectedKey={selectedTab}
onSelectionChange={(k) => setSelectedTab(k as string)}
>
<Tab key="my-shares" title="我分享的 (Provider)">
<Tab
key="my-shares"
title={
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
Provider
</span>
<span className="text-xs text-default-500">我分享的</span>
</div>
}
>
<Card>
<CardBody>
<div className="mb-4">
<CardBody className="space-y-5">
<div className="mt-4 flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">
Provider 共享
</h2>
<p className="text-sm text-default-500">
将本地节点分享给其他面板,统一管理
Token、端口范围和到期策略。
</p>
</div>
<Button
className="self-start md:self-auto"
color="primary"
onPress={() => setCreateShareOpen(true)}
>
@@ -410,9 +433,27 @@ export default function PanelSharingPage() {
</div>
{loading ? (
<div className="text-center py-10 text-gray-500">加载中...</div>
<div className="text-center py-12 text-default-500">
加载中...
</div>
) : shares.length === 0 ? (
<div className="text-center py-10 text-gray-500">暂无分享</div>
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
<p className="text-base font-semibold text-foreground">
暂无分享
</p>
<p className="mt-2 text-sm text-default-500">
先创建一个分享,把本地节点开放给其他面板使用。
</p>
<div className="mt-5 flex justify-center">
<Button
color="primary"
variant="flat"
onPress={() => setCreateShareOpen(true)}
>
创建第一个分享
</Button>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shares.map((share) => (
@@ -420,7 +461,7 @@ export default function PanelSharingPage() {
key={share.id}
className="border border-divider shadow-sm"
>
<CardHeader className="flex justify-between">
<CardHeader className="flex justify-between pb-2 md:pb-2">
<h3 className="font-bold">{share.name}</h3>
<div className="flex gap-2">
<Button
@@ -447,7 +488,7 @@ export default function PanelSharingPage() {
</Button>
</div>
</CardHeader>
<CardBody className="text-sm space-y-2">
<CardBody className="text-sm space-y-2 pt-0 md:pt-0">
<p>
端口范围: {share.portRangeStart} -{" "}
{share.portRangeEnd}
@@ -507,11 +548,30 @@ export default function PanelSharingPage() {
</CardBody>
</Card>
</Tab>
<Tab key="remote-nodes" title="远程节点 (Consumer)">
<Tab
key="remote-nodes"
title={
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
Consumer
</span>
<span className="text-xs text-default-500">远程节点</span>
</div>
}
>
<Card>
<CardBody>
<div className="mb-4">
<CardBody className="space-y-5">
<div className="mt-4 flex flex-col gap-4 rounded-lg border border-divider bg-default-50/60 dark:bg-default-100/20 p-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">
Consumer 接入
</h2>
<p className="text-sm text-default-500">
导入远程节点后,可在这里查看端口占用和同步状态。
</p>
</div>
<Button
className="self-start md:self-auto"
color="secondary"
onPress={() => setImportNodeOpen(true)}
>
@@ -520,13 +580,26 @@ export default function PanelSharingPage() {
</div>
{remoteUsageLoading ? (
<div className="text-center py-10 text-gray-500">加载中...</div>
<div className="text-center py-12 text-default-500">
加载中...
</div>
) : remoteUsageNodes.length === 0 ? (
<div className="text-center py-10 text-gray-500">
<p>暂无远程节点占用记录。</p>
<p className="mt-2">
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
<p className="text-base font-semibold text-foreground">
暂无远程节点占用记录
</p>
<p className="mt-2 text-sm text-default-500">
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
</p>
<div className="mt-5 flex justify-center">
<Button
color="secondary"
variant="flat"
onPress={() => setImportNodeOpen(true)}
>
去导入远程节点
</Button>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -535,13 +608,13 @@ export default function PanelSharingPage() {
key={node.nodeId}
className="border border-divider shadow-sm"
>
<CardHeader className="flex justify-between">
<CardHeader className="flex justify-between pb-2 md:pb-2">
<h3 className="font-bold">{node.nodeName}</h3>
<span className="text-xs text-default-500">
绑定 {node.activeBindingNum || 0}
</span>
</CardHeader>
<CardBody className="text-sm space-y-2">
<CardBody className="text-sm space-y-2 pt-0 md:pt-0">
{node.syncError && (
<div className="px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{node.syncError === "provider_share_deleted"
@@ -604,7 +677,11 @@ export default function PanelSharingPage() {
</Tabs>
{/* Create Share Modal */}
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)}>
<Modal
isOpen={createShareOpen}
scrollBehavior="inside"
onClose={() => setCreateShareOpen(false)}
>
<ModalContent>
<ModalHeader>创建分享</ModalHeader>
<ModalBody>
@@ -707,7 +784,11 @@ export default function PanelSharingPage() {
</Modal>
{/* Edit Share Modal */}
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
<Modal
isOpen={editShareOpen}
scrollBehavior="inside"
onClose={() => setEditShareOpen(false)}
>
<ModalContent>
<ModalHeader>编辑分享</ModalHeader>
<ModalBody>
+13 -19
View File
@@ -1,4 +1,7 @@
import React, { useState, useEffect } from "react";
import { toast } from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import {
@@ -10,11 +13,9 @@ import {
useDisclosure,
} from "@/shadcn-bridge/heroui/modal";
import { Input } from "@/shadcn-bridge/heroui/input";
import { toast } from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { isWebViewFunc } from "@/utils/panel";
import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
@@ -312,22 +313,15 @@ export default function ProfilePage() {
</CardBody>
</Card>
<div className="fixed inset-x-0 bottom-20 text-center py-4">
<p className="text-xs text-gray-400 dark:text-gray-500">
Powered by{" "}
<a
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
href={siteConfig.github_repo}
rel="noopener noreferrer"
target="_blank"
>
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
</p>
</div>
<VersionFooter
containerClassName="fixed inset-x-0 bottom-20 text-center py-4"
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
version={
isWebViewFunc() ? siteConfig.app_version : siteConfig.version
}
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
/>
</div>
{/* 修改密码弹窗 */}
+50 -3
View File
@@ -1,11 +1,17 @@
import { useState, useEffect } from "react";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { reinitializeBaseURL } from "@/api/network";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
import {
getPanelAddresses,
savePanelAddress,
@@ -25,6 +31,9 @@ export const SettingsPage = () => {
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
const [newName, setNewName] = useState("");
const [newAddress, setNewAddress] = useState("");
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress);
@@ -79,6 +88,14 @@ export const SettingsPage = () => {
loadPanelAddresses();
}, []);
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-black">
{/* 顶部导航 */}
@@ -117,6 +134,36 @@ export const SettingsPage = () => {
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
更新设置
</h2>
<div className="space-y-2">
<Select
label="更新通道"
selectedKeys={[updateChannel]}
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" textValue="stable">
稳定版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
开发版(含 alpha / beta / rc)
</SelectItem>
</Select>
<p className="text-xs text-gray-500 dark:text-gray-400">
版本提示会根据该通道检查最新版本。
</p>
</div>
</CardBody>
</Card>
{/* 添加新地址 */}
<Card className="border border-gray-200 dark:border-gray-700">
<CardBody className="p-6">
+229 -216
View File
@@ -1,20 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import toast from "react-hot-toast";
import {
DndContext,
@@ -34,6 +18,24 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Divider } from "@/shadcn-bridge/heroui/divider";
import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import {
createTunnel,
getTunnelList,
@@ -57,6 +59,7 @@ import {
getTunnelTypeDisplay,
validateTunnelForm,
} from "@/pages/tunnel/form";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
import { extractApiErrorMessage } from "@/api/error-message";
@@ -111,6 +114,11 @@ export default function TunnelPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
const [nodes, setNodes] = useState<Node[]>([]);
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"tunnel-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
@@ -285,37 +293,10 @@ export default function TunnelPage() {
}));
};
// 添加节点到指定的转发链跳数
const addNodeToChain = (groupIndex: number, nodeId: number) => {
setForm((prev) => {
const chainNodes = [...(prev.chainNodes || [])];
const group = chainNodes[groupIndex] || [];
// 获取当前组的策略和协议
const strategy = group.length > 0 ? group[0].strategy : "round";
const protocol = group.length > 0 ? group[0].protocol : "tls";
// 添加节点到该组
chainNodes[groupIndex] = [
...group,
{ nodeId, chainType: 2, protocol, strategy },
];
return { ...prev, chainNodes };
});
};
// 从某一跳删除指定节点
const removeNodeFromChain = (groupIndex: number, nodeId: number) => {
setForm((prev) => {
const chainNodes = [...(prev.chainNodes || [])];
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).filter(
(node) => node.nodeId !== nodeId,
);
return { ...prev, chainNodes };
});
const toSelectedNodeIds = (keys: Iterable<unknown>): number[] => {
return Array.from(keys)
.map((key) => Number.parseInt(String(key), 10))
.filter((nodeId) => Number.isFinite(nodeId));
};
// 更新某一跳的所有节点的协议
@@ -358,6 +339,51 @@ export default function TunnelPage() {
return form.chainNodes || [];
};
const mergeOrderedNodes = (
currentNodes: ChainTunnel[],
selectedNodeIds: number[],
buildDefault: (nodeId: number) => ChainTunnel,
): ChainTunnel[] => {
const selectedSet = new Set(selectedNodeIds);
const kept = currentNodes.filter((node) => selectedSet.has(node.nodeId));
const keptIds = new Set(kept.map((node) => node.nodeId));
const added = selectedNodeIds
.filter((nodeId) => !keptIds.has(nodeId))
.map((nodeId) => buildDefault(nodeId));
return [...kept, ...added];
};
const syncChainGroupNodes = (
groupIndex: number,
selectedNodeIds: number[],
) => {
setForm((prev) => {
const chainNodes = [...(prev.chainNodes || [])];
const currentGroup = chainNodes[groupIndex] || [];
const protocol = currentGroup[0]?.protocol || "tls";
const strategy = currentGroup[0]?.strategy || "round";
const realNodes = currentGroup.filter((node) => node.nodeId !== -1);
const mergedNodes = mergeOrderedNodes(
realNodes,
selectedNodeIds,
(nodeId) => ({
nodeId,
chainType: 2,
protocol,
strategy,
}),
);
chainNodes[groupIndex] =
mergedNodes.length > 0
? mergedNodes
: [{ nodeId: -1, chainType: 2, protocol, strategy }];
return { ...prev, chainNodes };
});
};
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return;
@@ -597,7 +623,19 @@ export default function TunnelPage() {
const sortedTunnels = useMemo((): Tunnel[] => {
if (!tunnels || tunnels.length === 0) return [];
const sortedByDb = [...tunnels].sort((a, b) => {
let filteredTunnels = tunnels;
if (searchKeyword.trim()) {
const lowerKeyword = searchKeyword.toLowerCase();
filteredTunnels = filteredTunnels.filter(
(t) =>
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),
);
}
const sortedByDb = [...filteredTunnels].sort((a, b) => {
const aInx = a.inx ?? 0;
const bInx = b.inx ?? 0;
@@ -610,7 +648,7 @@ export default function TunnelPage() {
tunnelOrder.length > 0 &&
sortedByDb.every((t) => t.inx === undefined || t.inx === 0)
) {
const tunnelMap = new Map(tunnels.map((t) => [t.id, t] as const));
const tunnelMap = new Map(filteredTunnels.map((t) => [t.id, t] as const));
const localSorted: Tunnel[] = [];
tunnelOrder.forEach((id) => {
@@ -619,7 +657,7 @@ export default function TunnelPage() {
if (tunnel) localSorted.push(tunnel);
});
tunnels.forEach((tunnel) => {
filteredTunnels.forEach((tunnel) => {
if (!tunnelOrder.includes(tunnel.id)) {
localSorted.push(tunnel);
}
@@ -629,7 +667,7 @@ export default function TunnelPage() {
}
return sortedByDb;
}, [tunnels, tunnelOrder]);
}, [tunnels, tunnelOrder, searchKeyword]);
const sortableTunnelIds = useMemo(
() => sortedTunnels.map((t) => t.id),
@@ -652,11 +690,17 @@ export default function TunnelPage() {
isDragging,
} = useSortable({ id });
const style = {
transform: transform ? CSS.Transform.toString(transform) : undefined,
const style: React.CSSProperties = {
transform: transform
? CSS.Transform.toString({
...transform,
x: Math.round(transform.x),
y: Math.round(transform.y),
})
: undefined,
transition: isDragging ? undefined : transition || undefined,
opacity: isDragging ? 0.5 : 1,
willChange: "transform",
willChange: isDragging ? "transform" : undefined,
};
return (
@@ -671,58 +715,94 @@ export default function TunnelPage() {
}
return (
<div className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button
color={selectMode ? "warning" : "default"}
size="sm"
variant={selectMode ? "solid" : "flat"}
onPress={toggleSelectMode}
>
{selectMode ? "退出" : "批量"}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<AnimatedPage className="px-3 lg:px-6 py-8">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索隧道名称或IP"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
/>
</div>
</div>
{selectMode && selectedIds.size > 0 && (
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
<div className="flex min-w-max items-center gap-2">
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button size="sm" variant="flat" onPress={selectAll}>
全选
</Button>
<Button size="sm" variant="flat" onPress={deselectAll}>
清空
</Button>
<Button
color="danger"
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
{selectMode ? (
<>
<span className="text-sm text-default-600 shrink-0">
已选择 {selectedIds.size} 项
</span>
<Button
color="primary"
size="sm"
variant="flat"
onPress={selectAll}
>
全选
</Button>
<Button
color="secondary"
size="sm"
variant="flat"
onPress={deselectAll}
>
清空
</Button>
<Button
color="danger"
isDisabled={selectedIds.size === 0}
size="sm"
variant="flat"
onPress={() => setBatchDeleteModalOpen(true)}
>
删除
</Button>
<Button
color="primary"
isDisabled={selectedIds.size === 0}
isLoading={batchLoading}
size="sm"
variant="flat"
onPress={handleBatchRedeploy}
>
下发
</Button>
<Button
color="secondary"
size="sm"
variant="solid"
onPress={toggleSelectMode}
>
退出
</Button>
</>
) : (
<>
<Button
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
color="default"
size="sm"
variant="flat"
onPress={toggleSelectMode}
>
批量
</Button>
<Button
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</>
)}
</div>
</div>
)}
</div>
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
@@ -740,9 +820,9 @@ export default function TunnelPage() {
{(listeners) => (
<Card
key={tunnel.id}
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
>
<CardHeader className="pb-2">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
{selectMode && (
<Checkbox
@@ -784,7 +864,7 @@ export default function TunnelPage() {
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-3">
{/* 拓扑结构 */}
<div className="pt-2 border-t border-divider">
@@ -1004,34 +1084,14 @@ export default function TunnelPage() {
</DndContext>
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<svg
aria-hidden="true"
className="w-8 h-8 text-default-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无隧道配置
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何隧道配置,点击上方按钮开始创建
</p>
</div>
</div>
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无隧道配置
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何隧道配置,点击上方按钮开始创建
</p>
</CardBody>
</Card>
)}
@@ -1117,11 +1177,6 @@ export default function TunnelPage() {
</Select>
<Input
endContent={
<div className="pointer-events-none flex items-center">
<span className="text-default-400 text-small">x</span>
</div>
}
errorMessage={errors.trafficRatio}
isInvalid={!!errors.trafficRatio}
label="流量倍率"
@@ -1160,6 +1215,7 @@ export default function TunnelPage() {
<Select
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
label="隧道连接地址偏好"
placeholder="自动选择"
selectedKeys={[form.ipPreference || ""]}
variant="bordered"
onSelectionChange={(keys) => {
@@ -1171,7 +1227,6 @@ export default function TunnelPage() {
}));
}}
>
<SelectItem key="">自动选择</SelectItem>
<SelectItem key="v4">优先IPv4</SelectItem>
<SelectItem key="v6">优先IPv6</SelectItem>
</Select>
@@ -1201,21 +1256,16 @@ export default function TunnelPage() {
selectionMode="multiple"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = Array.from(keys).map((key) =>
parseInt(key as string),
);
const newInNodeId: ChainTunnel[] = selectedIds.map(
(nodeId) => {
// 保留已有的端口配置
const existing = form.inNodeId.find(
(ct) => ct.nodeId === nodeId,
);
const selectedIds = toSelectedNodeIds(keys);
return existing || { nodeId, chainType: 1 };
},
);
setForm((prev) => ({ ...prev, inNodeId: newInNodeId }));
setForm((prev) => ({
...prev,
inNodeId: mergeOrderedNodes(
prev.inNodeId,
selectedIds,
(nodeId) => ({ nodeId, chainType: 1 }),
),
}));
}}
>
{nodes.map((node) => (
@@ -1374,6 +1424,7 @@ export default function TunnelPage() {
.filter((id) => id !== -1)
.map((id) => id.toString()),
]}
dropdownPlacement="top"
label="节点"
placeholder="选择节点(可多选)"
selectedKeys={groupNodes
@@ -1383,37 +1434,10 @@ export default function TunnelPage() {
size="sm"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = Array.from(
keys,
).map((key) => parseInt(key as string));
const currentNodes = groupNodes.filter(
(ct) => ct.nodeId !== -1,
syncChainGroupNodes(
groupIndex,
toSelectedNodeIds(keys),
);
// 找出新增的节点
const currentNodeIds = currentNodes.map(
(ct) => ct.nodeId,
);
const addedIds = selectedIds.filter(
(id) => !currentNodeIds.includes(id),
);
const removedIds =
currentNodeIds.filter(
(id) => !selectedIds.includes(id),
);
// 添加新节点
addedIds.forEach((nodeId) => {
addNodeToChain(groupIndex, nodeId);
});
// 删除取消选择的节点
removedIds.forEach((nodeId) => {
removeNodeFromChain(
groupIndex,
nodeId,
);
});
}}
>
{nodes.map((node) => (
@@ -1589,6 +1613,7 @@ export default function TunnelPage() {
id.toString(),
),
]}
dropdownPlacement="top"
errorMessage={errors.outNodeId}
isInvalid={!!errors.outNodeId}
label="节点"
@@ -1603,43 +1628,32 @@ export default function TunnelPage() {
selectionMode="multiple"
variant="bordered"
onSelectionChange={(keys) => {
const selectedIds = Array.from(keys).map((key) =>
parseInt(key as string),
);
const currentOutNodes = form.outNodeId || [];
const selectedIds = toSelectedNodeIds(keys);
let protocol = "tls";
let strategy = "round";
setForm((prev) => {
const currentOutNodes = prev.outNodeId || [];
const protocol =
currentOutNodes[0]?.protocol || "tls";
const strategy =
currentOutNodes[0]?.strategy || "round";
const realNodes = currentOutNodes.filter(
(ct) => ct.nodeId !== -1,
);
if (currentOutNodes.length > 0) {
protocol = currentOutNodes[0].protocol || "tls";
strategy =
currentOutNodes[0].strategy || "round";
}
const realNodes = currentOutNodes.filter(
(ct) => ct.nodeId !== -1,
);
const newOutNodeId: ChainTunnel[] =
selectedIds.map((nodeId) => {
const existing = realNodes.find(
(ct) => ct.nodeId === nodeId,
);
return (
existing || {
return {
...prev,
outNodeId: mergeOrderedNodes(
realNodes,
selectedIds,
(nodeId) => ({
nodeId,
chainType: 3,
protocol,
strategy,
}
);
});
setForm((prev) => ({
...prev,
outNodeId: newOutNodeId,
}));
}),
),
};
});
}}
>
{nodes.map((node) => (
@@ -2075,7 +2089,6 @@ export default function TunnelPage() {
</td>
<td className="px-3 py-2 text-center">
<Chip
className="min-w-[50px]"
color={
result.success
? "success"
@@ -2435,6 +2448,6 @@ export default function TunnelPage() {
)}
</ModalContent>
</Modal>
</div>
</AnimatedPage>
);
}
+287 -229
View File
@@ -1,4 +1,12 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import {
AnimatedPage,
StaggerList,
StaggerItem,
} from "@/components/animated-page";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Input } from "@/shadcn-bridge/heroui/input";
@@ -25,9 +33,6 @@ import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import {
User,
UserForm,
@@ -57,10 +62,10 @@ import {
SearchIcon,
EditIcon,
DeleteIcon,
UserIcon,
SettingsIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
// 工具函数
const formatFlow = (value: number, unit: string = "bytes"): string => {
@@ -121,7 +126,11 @@ export default function UserPage() {
// 状态管理
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"user-search-keyword",
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
size: 10,
@@ -210,6 +219,22 @@ export default function UserPage() {
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const noLimitSpeedLimitIds = useMemo(() => {
return new Set(
speedLimits
.filter((speedLimit) => speedLimit.name.trim() === "不限速")
.map((speedLimit) => speedLimit.id),
);
}, [speedLimits]);
const normalizeSpeedId = (speedId?: number | null): number | null => {
if (speedId === null || speedId === undefined) {
return null;
}
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
};
// 生命周期
useEffect(() => {
loadUsers();
@@ -423,7 +448,10 @@ export default function UserPage() {
try {
const tunnelsToAssign: TunnelAssignItem[] = Array.from(
batchTunnelSelections.entries(),
).map(([tunnelId, speedId]) => ({ tunnelId, speedId }));
).map(([tunnelId, speedId]) => ({
tunnelId,
speedId: normalizeSpeedId(speedId),
}));
const response = await batchAssignUserTunnel({
userId: currentUser.id,
@@ -447,6 +475,7 @@ export default function UserPage() {
const handleEditTunnel = (userTunnel: UserTunnel) => {
setEditTunnelForm({
...userTunnel,
speedId: normalizeSpeedId(userTunnel.speedId),
expTime: userTunnel.expTime,
});
onEditTunnelModalOpen();
@@ -463,7 +492,7 @@ export default function UserPage() {
num: editTunnelForm.num,
expTime: editTunnelForm.expTime,
flowResetTime: editTunnelForm.flowResetTime,
speedId: editTunnelForm.speedId,
speedId: normalizeSpeedId(editTunnelForm.speedId),
status: editTunnelForm.status,
});
@@ -574,13 +603,17 @@ export default function UserPage() {
};
const editAvailableSpeedLimits = speedLimits.filter(
(speedLimit) => speedLimit.tunnelId === editTunnelForm?.tunnelId,
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
);
const getSpeedLimitsForTunnel = (tunnelId: number) => {
return speedLimits.filter((sl) => sl.tunnelId === tunnelId);
const getSpeedLimitsForTunnel = (_tunnelId: number) => {
return speedLimits.filter(
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
);
};
const editTunnelSelectedSpeedId = normalizeSpeedId(editTunnelForm?.speedId);
const toggleTunnelSelection = (tunnelId: number) => {
setBatchTunnelSelections((prev) => {
const newMap = new Map(prev);
@@ -610,67 +643,88 @@ export default function UserPage() {
};
return (
<div className="px-3 lg:px-6 py-8">
<AnimatedPage className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-center gap-3" />
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
<div className="flex items-center gap-3 flex-1 max-w-md">
<Input
className="flex-1"
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索用户名"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
{!isSearchVisible ? (
<Button
isIconOnly
aria-label="搜索用户"
className="min-h-10 w-10"
color="primary"
variant="solid"
onPress={handleSearch}
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={() => setIsSearchVisible(true)}
>
<SearchIcon className="w-4 h-4" />
</Button>
</div>
<Button color="primary" variant="flat" onPress={handleAdd}>
新增
</Button>
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索用户名"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</div>
)}
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
{/* 用户列表 */}
{loading ? (
<PageLoadingState message="正在加载..." />
) : users.length === 0 ? (
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
<CardBody className="text-center py-16">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
<UserIcon className="w-8 h-8 text-default-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
暂无用户数据
</h3>
<p className="text-default-500 text-sm mt-1">
还没有创建任何用户,点击上方按钮开始创建
</p>
</div>
</div>
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无用户数据
</h3>
<p className="text-default-500 text-sm max-w-xs mx-auto leading-relaxed">
还没有创建任何用户,点击上方按钮开始创建
</p>
</CardBody>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{users.map((user) => {
const userStatus = getUserStatus(user);
const expStatus = user.expTime
@@ -686,169 +740,170 @@ export default function UserPage() {
: 0;
return (
<Card
key={user.id}
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
>
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">
{user.name || user.user}
</h3>
<p className="text-xs text-default-500 truncate">
@{user.user}
</p>
<StaggerItem key={user.id}>
<Card className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">
{user.name || user.user}
</h3>
<p className="text-xs text-default-500 truncate">
@{user.user}
</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
<Chip
className="text-xs"
color={userStatus.color}
size="sm"
variant="flat"
>
{userStatus.text}
</Chip>
</div>
</div>
<div className="flex items-center gap-1.5 ml-2">
<Chip
className="text-xs"
color={userStatus.color}
size="sm"
variant="flat"
>
{userStatus.text}
</Chip>
</div>
</div>
</CardHeader>
</CardHeader>
<CardBody className="pt-0 pb-3">
<div className="space-y-2">
{/* 流量信息 */}
<div className="space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-default-600">流量限制</span>
<span className="font-medium text-xs">
{formatFlow(user.flow, "gb")}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">已使用</span>
<span className="font-medium text-xs text-danger">
{formatFlow(usedFlow)}
</span>
</div>
<Progress
aria-label={`流量使用 ${flowPercent.toFixed(1)}%`}
className="mt-1"
color={
flowPercent > 90
? "danger"
: flowPercent > 70
? "warning"
: "success"
}
size="sm"
value={flowPercent}
/>
</div>
{/* 其他信息 */}
<div className="space-y-1.5 pt-2 border-t border-divider">
<div className="flex justify-between text-sm">
<span className="text-default-600">转发数量</span>
<span className="font-medium text-xs">{user.num}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">重置日期</span>
<span className="text-xs">
{user.flowResetTime === 0
? "不重置"
: `每月${user.flowResetTime}号`}
</span>
</div>
{user.expTime && (
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-2">
{/* 流量信息 */}
<div className="space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-default-600">过期时间</span>
<div className="text-right">
{expStatus && expStatus.color === "success" ? (
<div className="text-xs">
{formatDate(user.expTime)}
</div>
) : (
<Chip
className="text-xs"
color={expStatus?.color || "default"}
size="sm"
variant="flat"
>
{expStatus?.text || "未知状态"}
</Chip>
)}
</div>
<span className="text-default-600">流量限制</span>
<span className="font-medium text-xs">
{formatFlow(user.flow, "gb")}
</span>
</div>
)}
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">已使用</span>
<span className="font-medium text-xs text-danger">
{formatFlow(usedFlow)}
</span>
</div>
<Progress
aria-label={`流量使用 ${flowPercent.toFixed(1)}%`}
className="mt-1"
color={
flowPercent > 90
? "danger"
: flowPercent > 70
? "warning"
: "success"
}
size="sm"
value={flowPercent}
/>
</div>
<div className="space-y-1.5 mt-3">
{/* 第一行:编辑和重置 */}
<div className="flex gap-1.5">
<Button
className="flex-1 min-h-8"
color="primary"
size="sm"
startContent={<EditIcon className="w-3 h-3" />}
variant="flat"
onPress={() => handleEdit(user)}
>
编辑
</Button>
<Button
className="flex-1 min-h-8"
color="warning"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleResetFlow(user)}
>
重置
</Button>
{/* 其他信息 */}
<div className="space-y-1.5 pt-2 border-t border-divider">
<div className="flex justify-between text-sm">
<span className="text-default-600">转发数量</span>
<span className="font-medium text-xs">
{user.num}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">重置日期</span>
<span className="text-xs">
{user.flowResetTime === 0
? "不重置"
: `每月${user.flowResetTime}号`}
</span>
</div>
{user.expTime && (
<div className="flex justify-between text-sm">
<span className="text-default-600">过期时间</span>
<div className="text-right">
{expStatus && expStatus.color === "success" ? (
<div className="text-xs">
{formatDate(user.expTime)}
</div>
) : (
<Chip
className="text-xs"
color={expStatus?.color || "default"}
size="sm"
variant="flat"
>
{expStatus?.text || "未知状态"}
</Chip>
)}
</div>
</div>
)}
</div>
</div>
{/* 第二行:权限和删除 */}
<div className="flex gap-1.5">
<Button
className="flex-1 min-h-8"
color="success"
size="sm"
startContent={<SettingsIcon className="w-3 h-3" />}
variant="flat"
onPress={() => handleManageTunnels(user)}
>
权限
</Button>
<Button
className="flex-1 min-h-8"
color="danger"
size="sm"
startContent={<DeleteIcon className="w-3 h-3" />}
variant="flat"
onPress={() => handleDelete(user)}
>
删除
</Button>
<div className="space-y-1.5 mt-3">
{/* 第一行:编辑和重置 */}
<div className="flex gap-1.5">
<Button
className="flex-1 min-h-8"
color="primary"
size="sm"
startContent={<EditIcon className="w-3 h-3" />}
variant="flat"
onPress={() => handleEdit(user)}
>
编辑
</Button>
<Button
className="flex-1 min-h-8"
color="warning"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-3 h-3"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
fillRule="evenodd"
/>
</svg>
}
variant="flat"
onPress={() => handleResetFlow(user)}
>
重置
</Button>
</div>
{/* 第二行:权限和删除 */}
<div className="flex gap-1.5">
<Button
className="flex-1 min-h-8"
color="success"
size="sm"
startContent={<SettingsIcon className="w-3 h-3" />}
variant="flat"
onPress={() => handleManageTunnels(user)}
>
权限
</Button>
<Button
className="flex-1 min-h-8"
color="danger"
size="sm"
startContent={<DeleteIcon className="w-3 h-3" />}
variant="flat"
onPress={() => handleDelete(user)}
>
删除
</Button>
</div>
</div>
</div>
</CardBody>
</Card>
</CardBody>
</Card>
</StaggerItem>
);
})}
</div>
</StaggerList>
)}
{/* 用户表单模态框 */}
@@ -943,7 +998,6 @@ export default function UserPage() {
<DatePicker
isRequired
showMonthAndYearPickers
className="cursor-pointer"
label="过期时间"
value={
userForm.expTime
@@ -1082,13 +1136,12 @@ export default function UserPage() {
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
classNames={{
base: "pointer-events-none",
}}
color="primary"
isDisabled={isAssigned}
isSelected={isSelected}
size="md"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onValueChange={() =>
toggleTunnelSelection(tunnel.id)
}
@@ -1117,7 +1170,7 @@ export default function UserPage() {
classNames={{
trigger: "min-h-10 h-10",
}}
label="限速规则"
placeholder="请选择限速规则"
selectedKeys={
batchTunnelSelections.get(tunnel.id) !==
null &&
@@ -1168,17 +1221,22 @@ export default function UserPage() {
)}
</div>
<Button
className="w-full sm:w-auto"
color="primary"
isDisabled={batchTunnelSelections.size === 0}
isLoading={assignLoading}
onPress={handleBatchAssignTunnel}
>
分配权限{" "}
{batchTunnelSelections.size > 0 &&
`(${batchTunnelSelections.size}个隧道)`}
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
className="w-full sm:w-auto"
color="primary"
isDisabled={batchTunnelSelections.size === 0}
isLoading={assignLoading}
onPress={handleBatchAssignTunnel}
>
分配权限
</Button>
{batchTunnelSelections.size > 0 && (
<Chip color="primary" size="sm" variant="flat">
已选 {batchTunnelSelections.size} 个隧道
</Chip>
)}
</div>
</div>
</div>
@@ -1371,9 +1429,10 @@ export default function UserPage() {
<Select
label="限速规则"
placeholder="请选择限速规则"
selectedKeys={
editTunnelForm.speedId
? [editTunnelForm.speedId.toString()]
editTunnelSelectedSpeedId !== null
? [editTunnelSelectedSpeedId.toString()]
: ["null"]
}
onSelectionChange={(keys) => {
@@ -1435,7 +1494,6 @@ export default function UserPage() {
<DatePicker
isRequired
showMonthAndYearPickers
className="cursor-pointer"
label="到期时间"
value={
editTunnelForm.expTime
@@ -1765,6 +1823,6 @@ export default function UserPage() {
</ModalFooter>
</ModalContent>
</Modal>
</div>
</AnimatedPage>
);
}
+1 -1
View File
@@ -1,9 +1,9 @@
import * as React from "react";
import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
import { useHref, useNavigate } from "react-router-dom";
import { Toaster } from "react-hot-toast";
import { I18nProvider } from "@react-aria/i18n";
import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
import { ThemeProvider } from "@/components/theme-provider";
export interface ProvidersProps {
@@ -9,7 +9,8 @@ import {
type AlertColor = "default" | "success" | "warning" | "danger" | "primary";
type AlertVariant = "solid" | "flat" | "faded" | "bordered";
interface AlertProps extends Omit<React.ComponentProps<"div">, "color" | "title"> {
interface AlertProps
extends Omit<React.ComponentProps<"div">, "color" | "title"> {
color?: AlertColor;
description?: React.ReactNode;
title?: React.ReactNode;
@@ -4,8 +4,20 @@ import { Loader2Icon } from "lucide-react";
import { Button as BaseButton } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type HeroButtonColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
type HeroButtonVariant = "solid" | "light" | "flat" | "ghost" | "bordered" | "shadow";
type HeroButtonColor =
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
type HeroButtonVariant =
| "solid"
| "light"
| "flat"
| "ghost"
| "bordered"
| "shadow";
type HeroButtonSize = "sm" | "md" | "lg";
export interface ButtonProps
@@ -20,7 +32,17 @@ export interface ButtonProps
variant?: HeroButtonVariant;
}
function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "default" | "destructive" | "secondary" | "outline" | "ghost" | "light" | "flat" {
function mapVariant(
color: HeroButtonColor,
variant: HeroButtonVariant,
):
| "default"
| "destructive"
| "secondary"
| "outline"
| "ghost"
| "light"
| "flat" {
if (variant === "bordered") {
return "outline";
}
@@ -43,7 +65,10 @@ function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "defaul
return "default";
}
function mapSize(size: HeroButtonSize, isIconOnly: boolean): "default" | "sm" | "lg" | "icon" {
function mapSize(
size: HeroButtonSize,
isIconOnly: boolean,
): "default" | "sm" | "lg" | "icon" {
if (isIconOnly) {
return "icon";
}
@@ -67,33 +92,46 @@ const solidColorClassMap: Partial<Record<HeroButtonColor, string>> = {
const borderedColorClassMap: Record<HeroButtonColor, string> = {
default:
"border-default-300 text-default-700 hover:bg-default-100 dark:border-default-300 dark:text-default-600 dark:hover:bg-default-200/40",
primary: "border-primary text-primary hover:bg-primary-50 dark:border-primary-500/60 dark:text-primary-300 dark:hover:bg-primary-900/20",
primary:
"border-primary text-primary hover:bg-primary-50 dark:border-primary-500/60 dark:text-primary-300 dark:hover:bg-primary-900/20",
secondary:
"border-secondary text-secondary hover:bg-secondary-50 dark:border-secondary-500/60 dark:text-secondary-300 dark:hover:bg-secondary-900/20",
success: "border-success text-success hover:bg-success-50 dark:border-success-500/60 dark:text-success-300 dark:hover:bg-success-900/20",
success:
"border-success text-success hover:bg-success-50 dark:border-success-500/60 dark:text-success-300 dark:hover:bg-success-900/20",
warning:
"border-warning text-warning-700 hover:bg-warning-50 dark:border-warning-500/60 dark:text-warning-300 dark:hover:bg-warning-900/20",
danger: "border-danger text-danger hover:bg-danger-50 dark:border-danger-500/60 dark:text-danger-300 dark:hover:bg-danger-900/20",
danger:
"border-danger text-danger hover:bg-danger-50 dark:border-danger-500/60 dark:text-danger-300 dark:hover:bg-danger-900/20",
};
const lightColorClassMap: Record<HeroButtonColor, string> = {
default: "text-default-700 hover:bg-default-100 dark:text-default-600 dark:hover:bg-default-200/40",
primary: "text-primary hover:bg-primary-100/70 dark:text-primary-300 dark:hover:bg-primary-900/30",
secondary: "text-secondary hover:bg-secondary-100/70 dark:text-secondary-300 dark:hover:bg-secondary-900/30",
success: "text-success hover:bg-success-100/70 dark:text-success-300 dark:hover:bg-success-900/30",
warning: "text-warning-700 hover:bg-warning-100/70 dark:text-warning-300 dark:hover:bg-warning-900/30",
danger: "text-danger hover:bg-danger-100/70 dark:text-danger-300 dark:hover:bg-danger-900/30",
default:
"text-default-700 hover:bg-default-100 dark:text-default-600 dark:hover:bg-default-200/40",
primary:
"text-primary hover:bg-primary-100/70 dark:text-primary-300 dark:hover:bg-primary-900/30",
secondary:
"text-secondary hover:bg-secondary-100/70 dark:text-secondary-300 dark:hover:bg-secondary-900/30",
success:
"text-success hover:bg-success-100/70 dark:text-success-300 dark:hover:bg-success-900/30",
warning:
"text-warning-700 hover:bg-warning-100/70 dark:text-warning-300 dark:hover:bg-warning-900/30",
danger:
"text-danger hover:bg-danger-100/70 dark:text-danger-300 dark:hover:bg-danger-900/30",
};
const flatColorClassMap: Record<HeroButtonColor, string> = {
default:
"bg-default-100 text-default-700 hover:bg-default-200 dark:bg-default-100/45 dark:text-default-700 dark:hover:bg-default-200/60",
primary: "bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/45",
primary:
"bg-primary-100 text-primary-700 hover:bg-primary-200 dark:bg-primary-900/30 dark:text-primary-300 dark:hover:bg-primary-900/45",
secondary:
"bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-900/30 dark:text-secondary-300 dark:hover:bg-secondary-900/45",
success: "bg-success-100 text-success-700 hover:bg-success-200 dark:bg-success-900/30 dark:text-success-300 dark:hover:bg-success-900/45",
warning: "bg-warning-100 text-warning-700 hover:bg-warning-200 dark:bg-warning-900/30 dark:text-warning-300 dark:hover:bg-warning-900/45",
danger: "bg-danger-100 text-danger-700 hover:bg-danger-200 dark:bg-danger-900/30 dark:text-danger-300 dark:hover:bg-danger-900/45",
success:
"bg-success-100 text-success-700 hover:bg-success-200 dark:bg-success-900/30 dark:text-success-300 dark:hover:bg-success-900/45",
warning:
"bg-warning-100 text-warning-700 hover:bg-warning-200 dark:bg-warning-900/30 dark:text-warning-300 dark:hover:bg-warning-900/45",
danger:
"bg-danger-100 text-danger-700 hover:bg-danger-200 dark:bg-danger-900/30 dark:text-danger-300 dark:hover:bg-danger-900/45",
};
const shadowColorClassMap: Record<HeroButtonColor, string> = {
@@ -105,7 +143,10 @@ const shadowColorClassMap: Record<HeroButtonColor, string> = {
danger: "shadow-md shadow-danger-500/35",
};
function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): string {
function mapColorClass(
color: HeroButtonColor,
variant: HeroButtonVariant,
): string {
if (variant === "bordered") {
return borderedColorClassMap[color];
}
@@ -122,7 +163,10 @@ function mapColorClass(color: HeroButtonColor, variant: HeroButtonVariant): stri
return "";
}
function mapShadowClass(color: HeroButtonColor, variant: HeroButtonVariant): string {
function mapShadowClass(
color: HeroButtonColor,
variant: HeroButtonVariant,
): string {
if (variant !== "shadow") {
return "";
}
@@ -169,16 +213,23 @@ export const Button = React.forwardRef<
return (
<BaseButton
className={cn(isIconOnly ? "p-0" : "", resolvedColorClass, resolvedShadowClass, className)}
disabled={resolvedDisabled}
ref={ref}
className={cn(
isIconOnly ? "p-0" : "",
resolvedColorClass,
resolvedShadowClass,
className,
)}
disabled={resolvedDisabled}
size={resolvedSize}
type={type}
variant={resolvedVariant}
onClick={handleClick}
{...props}
>
{isLoading ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : null}
{isLoading ? (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
) : null}
{startContent}
{isIconOnly ? null : children}
{isIconOnly ? children : null}
@@ -11,10 +11,18 @@ export function Card({ className, ...props }: React.ComponentProps<"div">) {
return <BaseCard className={className} {...props} />;
}
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
export function CardHeader({
className,
...props
}: React.ComponentProps<"div">) {
return <BaseCardHeader className={cn("p-4 md:p-6", className)} {...props} />;
}
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
return <CardContent className={cn("p-4 md:p-6", className)} {...props} />;
return (
<CardContent
className={cn("p-4 pt-0 md:p-6 md:pt-0", className)}
{...props}
/>
);
}
@@ -4,7 +4,10 @@ import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
export interface CheckboxProps
extends Omit<React.ComponentProps<typeof BaseCheckbox>, "checked" | "onCheckedChange"> {
extends Omit<
React.ComponentProps<typeof BaseCheckbox>,
"checked" | "onCheckedChange"
> {
classNames?: Record<string, string>;
color?: string;
isDisabled?: boolean;
@@ -30,7 +33,13 @@ export function Checkbox({
};
return (
<div className={cn("inline-flex items-center gap-2", isDisabled ? "opacity-50" : "", className)}>
<div
className={cn(
"inline-flex items-center gap-2",
isDisabled ? "opacity-50" : "",
className,
)}
>
<BaseCheckbox
checked={Boolean(isSelected)}
disabled={isDisabled}
@@ -3,7 +3,13 @@ import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type ChipColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
type ChipColor =
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
type ChipVariant = "solid" | "flat" | "light" | "bordered";
type ChipSize = "sm" | "md" | "lg";
@@ -1,16 +1,60 @@
import * as React from "react";
import { FieldContainer, type FieldMetaProps } from "./shared";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { FieldContainer, type FieldMetaProps } from "./shared";
interface CalendarDateLike {
day: number;
month: number;
year: number;
}
function isValidCalendarDate(year: number, month: number, day: number) {
if (
!Number.isInteger(year) ||
!Number.isInteger(month) ||
!Number.isInteger(day)
) {
return false;
}
if (month < 1 || month > 12 || day < 1 || day > 31) {
return false;
}
const candidate = new Date(year, month - 1, day);
return (
candidate.getFullYear() === year &&
candidate.getMonth() === month - 1 &&
candidate.getDate() === day
);
}
function parseDateText(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const matched = trimmed.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/);
if (!matched) {
return null;
}
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
if (!isValidCalendarDate(year, month, day)) {
return null;
}
return { day, month, year };
}
export interface DatePickerProps extends FieldMetaProps {
className?: string;
isDisabled?: boolean;
@@ -39,9 +83,80 @@ export function DatePicker({
isRequired,
label,
onChange,
showMonthAndYearPickers,
value,
}: DatePickerProps) {
const id = React.useId();
const formattedValue = React.useMemo(() => formatDateValue(value), [value]);
const [textValue, setTextValue] = React.useState(formattedValue);
React.useEffect(() => {
setTextValue(formattedValue);
}, [formattedValue]);
const shouldUseTextInput = Boolean(showMonthAndYearPickers);
const notifyNativeDateChange = (rawValue: string) => {
if (!onChange) {
return;
}
if (!rawValue) {
onChange(null);
return;
}
const [yearText, monthText, dayText] = rawValue.split("-");
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (!isValidCalendarDate(year, month, day)) {
onChange(null);
return;
}
onChange({ day, month, year });
};
const notifyTextDateChange = (rawValue: string) => {
if (!onChange) {
return;
}
if (!rawValue.trim()) {
onChange(null);
return;
}
const parsed = parseDateText(rawValue);
if (parsed) {
onChange(parsed);
}
};
const commitTextInput = () => {
const parsed = parseDateText(textValue);
if (parsed) {
setTextValue(formatDateValue(parsed));
onChange?.(parsed);
return;
}
if (!textValue.trim()) {
onChange?.(null);
return;
}
setTextValue(formattedValue);
};
return (
<FieldContainer
@@ -57,33 +172,34 @@ export function DatePicker({
className={cn(className)}
disabled={isDisabled}
id={id}
inputMode={shouldUseTextInput ? "numeric" : undefined}
placeholder={shouldUseTextInput ? "YYYY-MM-DD" : undefined}
required={isRequired}
type="date"
value={formatDateValue(value)}
type={shouldUseTextInput ? "text" : "date"}
value={shouldUseTextInput ? textValue : formattedValue}
onBlur={shouldUseTextInput ? commitTextInput : undefined}
onChange={(event) => {
if (!onChange) {
return;
}
const nextValue = event.target.value;
if (!event.target.value) {
onChange(null);
if (shouldUseTextInput) {
setTextValue(nextValue);
notifyTextDateChange(nextValue);
return;
}
const [yearText, monthText, dayText] = event.target.value.split("-");
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
onChange(null);
return;
}
onChange({ day, month, year });
notifyNativeDateChange(nextValue);
}}
onKeyDown={
shouldUseTextInput
? (event) => {
if (event.key === "Enter") {
event.preventDefault();
commitTextInput();
}
}
: undefined
}
/>
</FieldContainer>
);
@@ -8,7 +8,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger as BaseDropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
@@ -17,14 +16,19 @@ interface DropdownContextValue {
align: "start" | "end";
}
const DropdownContext = React.createContext<DropdownContextValue>({ align: "start" });
const DropdownContext = React.createContext<DropdownContextValue>({
align: "start",
});
export interface DropdownProps {
children: React.ReactNode;
placement?: Placement;
}
export function Dropdown({ children, placement = "bottom-start" }: DropdownProps) {
export function Dropdown({
children,
placement = "bottom-start",
}: DropdownProps) {
const align = placement.endsWith("end") ? "end" : "start";
return (
@@ -66,7 +70,10 @@ export function DropdownItem({
}: DropdownItemProps) {
return (
<BaseDropdownMenuItem
className={cn(color === "danger" ? "text-danger focus:text-danger" : "", className)}
className={cn(
color === "danger" ? "text-danger focus:text-danger" : "",
className,
)}
onSelect={(event) => {
event.preventDefault();
onPress?.();
@@ -1,11 +1,11 @@
import * as React from "react";
import { FieldContainer, type FieldMetaProps } from "./shared";
import { Input as BaseInput } from "@/components/ui/input";
import { Textarea as BaseTextarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { FieldContainer, type FieldMetaProps } from "./shared";
type ClassNameMap = {
base?: string;
description?: string;
@@ -84,7 +84,9 @@ export function Input({
classNames?.inputWrapper,
)}
>
{startContent ? <div className="pl-3 text-default-500">{startContent}</div> : null}
{startContent ? (
<div className="pl-3 text-default-500">{startContent}</div>
) : null}
<BaseInput
aria-invalid={isInvalid}
className={cn(
@@ -100,7 +102,9 @@ export function Input({
required={isRequired}
{...props}
/>
{endContent ? <div className="pr-3 text-default-500">{endContent}</div> : null}
{endContent ? (
<div className="pr-3 text-default-500">{endContent}</div>
) : null}
</div>
</FieldContainer>
);
@@ -19,6 +19,18 @@ function mapColor(color: LinkColor) {
return "text-default-600 hover:text-default-700";
}
export function Link({ className, color = "default", ...props }: LinkProps) {
return <a className={cn("transition-colors", mapColor(color), className)} {...props} />;
export function Link({
className,
color = "default",
children,
...props
}: LinkProps) {
return (
<a
className={cn("transition-colors", mapColor(color), className)}
{...props}
>
{children}
</a>
);
}
@@ -207,7 +207,7 @@ export function ModalFooter({
return (
<div
className={cn(
"mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"mt-4 flex flex-wrap justify-end gap-2",
context?.classNames?.footer,
className,
)}
@@ -52,7 +52,12 @@ export function Navbar({
style={height ? { minHeight: height } : undefined}
{...props}
>
<div className={cn("mx-auto flex h-full w-full items-center justify-between px-4", maxWidthClass(maxWidth))}>
<div
className={cn(
"mx-auto flex h-full w-full items-center justify-between px-4",
maxWidthClass(maxWidth),
)}
>
{children}
</div>
</nav>
@@ -85,6 +90,9 @@ export function NavbarContent({
);
}
export function NavbarBrand({ className, ...props }: React.ComponentProps<"div">) {
export function NavbarBrand({
className,
...props
}: React.ComponentProps<"div">) {
return <div className={cn("flex items-center", className)} {...props} />;
}
@@ -3,7 +3,13 @@ import * as React from "react";
import { Progress as BaseProgress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
type ProgressColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
type ProgressColor =
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "danger";
export interface ProgressProps {
"aria-label"?: string;
@@ -1,6 +1,9 @@
import * as React from "react";
import { RadioGroup as BaseRadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
RadioGroup as BaseRadioGroup,
RadioGroupItem,
} from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
@@ -8,7 +11,9 @@ interface RadioContextValue {
name: string;
}
const RadioContext = React.createContext<RadioContextValue>({ name: "radio-group" });
const RadioContext = React.createContext<RadioContextValue>({
name: "radio-group",
});
export interface RadioGroupProps {
children: React.ReactNode;
@@ -33,7 +38,9 @@ export function RadioGroup({
<RadioContext.Provider value={{ name: generatedName }}>
<BaseRadioGroup
className={cn(
orientation === "horizontal" ? "flex flex-wrap items-center gap-4" : "grid gap-3",
orientation === "horizontal"
? "flex flex-wrap items-center gap-4"
: "grid gap-3",
)}
value={value}
onValueChange={onValueChange}

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