mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c10727e08 | |||
| 841d43344a | |||
| 5efe790937 | |||
| eec6cb4298 | |||
| 352fc82907 |
@@ -0,0 +1,132 @@
|
||||
# Floating Save Button Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a floating save button (FAB) to the config page that appears when configuration changes are detected.
|
||||
|
||||
**Architecture:** Inline FAB implementation using framer-motion AnimatePresence for enter/exit animations. Fixed-position circular button with slide-up animation, reusing existing hasChanges state and handleSave function.
|
||||
|
||||
**Tech Stack:** React, framer-motion (v11.18.2), shadcn-bridge/heroui Button, Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `vite-frontend/src/pages/config.tsx` | Modify | Add FAB imports and component at page bottom |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add framer-motion Imports
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx:1-5`
|
||||
|
||||
- [ ] **Step 1: Add AnimatePresence and motion imports**
|
||||
|
||||
Add import statement after existing framer-motion imports (or at top if none exist).
|
||||
|
||||
Current imports at line 1-2:
|
||||
```typescript
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
```
|
||||
|
||||
Add new import after line 2:
|
||||
```typescript
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit import addition**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/config.tsx
|
||||
git commit -m "feat(config): add framer-motion imports for FAB animation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add FAB Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `vite-frontend/src/pages/config.tsx:1220-1224` (end of component)
|
||||
|
||||
- [ ] **Step 1: Add FAB at end of component (before closing div)**
|
||||
|
||||
Locate the end of `ConfigPage` component (line ~1223, the closing `</div>` after all modals).
|
||||
|
||||
Insert FAB component before the closing `</div>`:
|
||||
|
||||
```tsx
|
||||
{/* Floating Save Button (FAB) */}
|
||||
<AnimatePresence>
|
||||
{hasChanges && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="fixed bottom-6 right-6 z-50"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="primary"
|
||||
size="lg"
|
||||
className="w-12 h-12 rounded-full shadow-lg"
|
||||
isLoading={saving}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{!saving && <SaveIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run dev server to verify**
|
||||
|
||||
```bash
|
||||
cd vite-frontend && npm run dev
|
||||
```
|
||||
|
||||
Manual verification checklist:
|
||||
- Open config page at http://localhost:3000/config
|
||||
- Modify any config field
|
||||
- Verify FAB appears with slide-up animation
|
||||
- Click FAB to save
|
||||
- Verify FAB disappears with slide-down animation after save
|
||||
- Scroll page and verify FAB stays fixed in viewport corner
|
||||
- Test on mobile viewport (resize browser or use dev tools)
|
||||
|
||||
- [ ] **Step 3: Commit FAB implementation**
|
||||
|
||||
```bash
|
||||
git add vite-frontend/src/pages/config.tsx
|
||||
git commit -m "feat(config): add floating save button (FAB) for issue #266"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Summary
|
||||
|
||||
| Requirement | Verification Method |
|
||||
|-------------|---------------------|
|
||||
| FAB hidden by default | Visual: no FAB on page load with no changes |
|
||||
| FAB appears on change | Visual: modify field → FAB slides up |
|
||||
| Fixed position | Visual: scroll page → FAB stays in corner |
|
||||
| Slide-up animation | Visual: observe animation timing/bounce |
|
||||
| Slide-down on save | Visual: click save → FAB slides down |
|
||||
| Loading state | Visual: click save → spinner shown during save |
|
||||
| Mobile compatibility | Visual: resize to mobile viewport → same behavior |
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [x] Spec coverage: All requirements from design doc covered (imports + FAB component, animation params, button style, interaction behavior)
|
||||
- [x] No placeholders: All code shown, no TBD/TODO
|
||||
- [x] Type consistency: SaveIcon (line 45-59), handleSave (line 372-434), hasChanges (line 214), saving (line 213) all exist in config.tsx
|
||||
@@ -0,0 +1,162 @@
|
||||
# Floating Save Button Design
|
||||
|
||||
**Date:** 2026-04-01
|
||||
**Issue:** https://github.com/Sagit-chu/flvx/issues/266
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add a Floating Action Button (FAB) to the config page (`vite-frontend/src/pages/config.tsx`) that appears when configuration changes are detected, allowing users to save without scrolling to the top.
|
||||
|
||||
## Requirements
|
||||
|
||||
From Issue #266:
|
||||
|
||||
1. **Default hidden**: FAB not visible when no config changes
|
||||
2. **Show on change**: Auto-display when `hasChanges` becomes true
|
||||
3. **Fixed position**: Suspended at bottom-right corner, does not scroll with page
|
||||
4. **Mobile compatible**: Same behavior on desktop and mobile devices
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Implementation Approach
|
||||
|
||||
**Inline FAB in config.tsx** (not a reusable component)
|
||||
|
||||
- Rationale: Current need is limited to config page only
|
||||
- State management (`hasChanges`, `saving`) already exists in the page
|
||||
- framer-motion patterns already established in project
|
||||
- Avoids over-abstraction (YAGNI)
|
||||
|
||||
### 2. UI Structure
|
||||
|
||||
Position: `fixed bottom-6 right-6` (24px from viewport edges)
|
||||
|
||||
Visual layout:
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ [页面内容,可滚动] │
|
||||
│ │
|
||||
│ [●] │ ← FAB (fixed position)
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. Button Appearance
|
||||
|
||||
- Shape: Circular (`w-12 h-12 rounded-full`)
|
||||
- Color: Primary (matches existing save button)
|
||||
- Icon: SaveIcon (already defined in config.tsx)
|
||||
- Shadow: `shadow-lg` for visual hierarchy
|
||||
- Style: Icon-only (no text label)
|
||||
|
||||
### 4. Animation
|
||||
|
||||
Using framer-motion with `AnimatePresence`:
|
||||
|
||||
| Phase | Properties |
|
||||
|-------|------------|
|
||||
| `initial` | `{ y: 100, opacity: 0 }` - starts below viewport |
|
||||
| `animate` | `{ y: 0, opacity: 1 }` - slides up to position |
|
||||
| `exit` | `{ y: 100, opacity: 0 }` - slides back down on hide |
|
||||
|
||||
Transition config:
|
||||
```typescript
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
```
|
||||
|
||||
Spring parameters produce Material Design-like feel: smooth entrance, slight bounce settle.
|
||||
|
||||
### 5. Interaction Details
|
||||
|
||||
- **Click**: Calls existing `handleSave()` function
|
||||
- **Loading state**: Button shows Spinner when `saving === true`
|
||||
- **Hover**: Inherits Button component's primary color hover behavior
|
||||
- **z-index**: `z-50` (above page content, below modals)
|
||||
- **Prevent duplicate click**: Button disabled when `saving === true`
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Code Location
|
||||
|
||||
File: `vite-frontend/src/pages/config.tsx`
|
||||
|
||||
### Required Imports
|
||||
|
||||
```typescript
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
```
|
||||
|
||||
### FAB Component Structure
|
||||
|
||||
```tsx
|
||||
<AnimatePresence>
|
||||
{hasChanges && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="fixed bottom-6 right-6 z-50"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="primary"
|
||||
size="lg"
|
||||
className="w-12 h-12 rounded-full shadow-lg"
|
||||
isLoading={saving}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{!saving && <SaveIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
### Placement
|
||||
|
||||
Insert FAB at the end of the component, before the closing `</div>` (after all Cards and Modals).
|
||||
|
||||
### Dependencies
|
||||
|
||||
- framer-motion: Already installed (v11.18.2)
|
||||
- Button: Already imported from `@/shadcn-bridge/heroui/button`
|
||||
- SaveIcon: Already defined in config.tsx
|
||||
|
||||
## Behavior Matrix
|
||||
|
||||
| State | FAB Visibility | Button Enabled |
|
||||
|-------|----------------|----------------|
|
||||
| `hasChanges = false` | Hidden (not rendered) | N/A |
|
||||
| `hasChanges = true, saving = false` | Visible, animating in | Yes |
|
||||
| `hasChanges = true, saving = true` | Visible | No (loading) |
|
||||
| Save success | Hidden (animating out) | N/A |
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
No special handling needed. `fixed bottom-6 right-6` works identically on:
|
||||
- Desktop browsers
|
||||
- Mobile browsers
|
||||
- H5/WebView mode
|
||||
|
||||
The FAB maintains consistent 24px margin from viewport edges regardless of screen size.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Multiple rapid toggles**: AnimatePresence handles gracefully - exit animation completes before new enter animation
|
||||
2. **Page unload with unsaved changes**: Not addressed in this design (separate concern)
|
||||
3. **FAB covers existing warning banner**: z-50 places FAB above the warning banner at line 1004-1013
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After implementation, verify:
|
||||
|
||||
- [ ] FAB appears when any config field is modified
|
||||
- [ ] FAB slides up from bottom on appearance
|
||||
- [ ] FAB slides down to bottom on disappearance
|
||||
- [ ] FAB fixed position during page scroll
|
||||
- [ ] FAB triggers save on click
|
||||
- [ ] FAB shows spinner during save
|
||||
- [ ] FAB disappears after successful save
|
||||
- [ ] FAB works on mobile viewport
|
||||
- [ ] FAB does not interfere with Modal dialogs
|
||||
@@ -1577,10 +1577,11 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
|
||||
}
|
||||
var serviceAddr string
|
||||
if bindIP != "" {
|
||||
if strings.Contains(bindIP, ":") {
|
||||
serviceAddr = processServerAddress(bindIP)
|
||||
trimmedBindIP := strings.TrimSpace(bindIP)
|
||||
if _, _, err := net.SplitHostPort(trimmedBindIP); err == nil {
|
||||
serviceAddr = processServerAddress(trimmedBindIP)
|
||||
} else {
|
||||
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", bindIP, port))
|
||||
serviceAddr = processServerAddress(net.JoinHostPort(strings.Trim(trimmedBindIP, "[]"), strconv.Itoa(port)))
|
||||
}
|
||||
} else {
|
||||
serviceAddr = processServerAddress(fmt.Sprintf("%s:%d", listenerAddr, port))
|
||||
|
||||
@@ -421,6 +421,63 @@ func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bindIP string
|
||||
port int
|
||||
wantAddr string
|
||||
}{
|
||||
{
|
||||
name: "pure ipv6 without port",
|
||||
bindIP: "2001:db8::1",
|
||||
port: 22000,
|
||||
wantAddr: "[2001:db8::1]:22000",
|
||||
},
|
||||
{
|
||||
name: "bracketed ipv6 without port",
|
||||
bindIP: "[2001:db8::2]",
|
||||
port: 22001,
|
||||
wantAddr: "[2001:db8::2]:22001",
|
||||
},
|
||||
{
|
||||
name: "bracketed ipv6 with port",
|
||||
bindIP: "[2001:db8::3]:8080",
|
||||
port: 55555,
|
||||
wantAddr: "[2001:db8::3]:8080",
|
||||
},
|
||||
{
|
||||
name: "ipv6 link-local with zone",
|
||||
bindIP: "fe80::1%eth0",
|
||||
port: 22002,
|
||||
wantAddr: "[fe80::1%eth0]:22002",
|
||||
},
|
||||
{
|
||||
name: "ipv6 localhost",
|
||||
bindIP: "::1",
|
||||
port: 22003,
|
||||
wantAddr: "[::1]:22003",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
|
||||
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
|
||||
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, false)
|
||||
if len(services) != 2 {
|
||||
t.Fatalf("expected 2 services, got %d", len(services))
|
||||
}
|
||||
for _, svc := range services {
|
||||
addr, _ := svc["addr"].(string)
|
||||
if addr != tt.wantAddr {
|
||||
t.Fatalf("expected addr %q, got %q", tt.wantAddr, addr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessServerAddress_StripsURLSchemeAndPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -22,6 +22,7 @@ type userTunnelPolicy struct {
|
||||
OutFlow int64
|
||||
ExpTime int64
|
||||
Status int
|
||||
Num int
|
||||
}
|
||||
|
||||
type gostConfigSnapshot struct {
|
||||
@@ -363,6 +364,16 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Num > 0 {
|
||||
currentForwardCount, err := h.repo.CountActiveForwardsByUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentForwardCount >= int64(user.Num) {
|
||||
return errors.New("转发数量已达上限")
|
||||
}
|
||||
}
|
||||
|
||||
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(userID, tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -392,6 +403,16 @@ func (h *Handler) ensureUserTunnelForwardAllowed(userID int64, tunnelID int64, n
|
||||
return errors.New("该隧道流量已超额,禁止开启转发")
|
||||
}
|
||||
|
||||
if policy.Num > 0 {
|
||||
currentTunnelForwardCount, err := h.repo.CountActiveForwardsByUserTunnel(userID, tunnelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if currentTunnelForwardCount >= int64(policy.Num) {
|
||||
return errors.New("该隧道转发数量已达上限")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -442,7 +463,7 @@ func (h *Handler) getUserTunnelPolicy(userTunnelID int64) (*userTunnelPolicy, er
|
||||
return &userTunnelPolicy{
|
||||
ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,
|
||||
Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,
|
||||
ExpTime: ut.ExpTime, Status: ut.Status,
|
||||
ExpTime: ut.ExpTime, Status: ut.Status, Num: ut.Num,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -683,6 +683,7 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
||||
"remoteToken": nullableString(n.RemoteToken),
|
||||
"remoteConfig": nullableString(n.RemoteConfig),
|
||||
"expiryReminderDismissed": n.ExpiryReminderDismissed,
|
||||
"interfaceName": nullableString(n.InterfaceName),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
|
||||
@@ -262,6 +262,24 @@ func (r *Repository) SpeedLimitExists(id int64) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountActiveForwardsByUser(userID int64) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.Forward{}).Where("user_id = ? AND status = 1", userID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *Repository) CountActiveForwardsByUserTunnel(userID, tunnelID int64) (int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.Forward{}).Where("user_id = ? AND tunnel_id = ? AND status = 1", userID, tunnelID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *Repository) GetSpeedLimitSpeed(id int64) (int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, errors.New("repository not initialized")
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
)
|
||||
|
||||
func TestForwardCreateBlockedWhenUserNumLimitExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(100)
|
||||
tunnelID := int64(1)
|
||||
|
||||
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(?, 'num_limit_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 2, ?, ?, 1)
|
||||
`, userID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'num_limit_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(1, ?, 'num_limit_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 1: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(2, ?, 'num_limit_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 2: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "num_limit_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"new_forward","remoteAddr":"1.1.1.1:53"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
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 non-zero code when num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
if !strings.Contains(out.Msg, "转发数量已达上限") {
|
||||
t.Fatalf("expected forward count limit message, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardResumeBlockedWhenUserNumLimitExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(101)
|
||||
tunnelID := int64(1)
|
||||
pausedForwardID := int64(3)
|
||||
|
||||
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(?, 'num_resume_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 2, ?, ?, 1)
|
||||
`, userID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'num_resume_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(1, ?, 'num_resume_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 1: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(2, ?, 'num_resume_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 2: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(?, ?, 'num_resume_user', 'paused_forward', ?, '1.1.1.1:53', 'fifo', 0, 0, ?, ?, 0, 0)
|
||||
`, pausedForwardID, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert paused forward: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "num_resume_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewBufferString(`{"id":3}`))
|
||||
req.Header.Set("Authorization", token)
|
||||
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 non-zero code when num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
if !strings.Contains(out.Msg, "转发数量已达上限") {
|
||||
t.Fatalf("expected forward count limit message, got %q", out.Msg)
|
||||
}
|
||||
|
||||
status := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, pausedForwardID)
|
||||
if status != 0 {
|
||||
t.Fatalf("expected forward status to remain 0, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateBlockedWhenUserTunnelNumLimitExceeded(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(102)
|
||||
tunnelID := int64(1)
|
||||
|
||||
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(?, 'ut_num_limit_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||
`, userID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'ut_num_limit_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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, ?, ?, NULL, 1, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel with num=1: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(1, ?, 'ut_num_limit_user', 'existing_tunnel_forward', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "ut_num_limit_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"new_tunnel_forward","remoteAddr":"1.1.1.1:53"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
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 non-zero code when user_tunnel num limit exceeded, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
if !strings.Contains(out.Msg, "隧道转发数量已达上限") {
|
||||
t.Fatalf("expected tunnel forward count limit message, got %q", out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateAllowedWhenBelowUserNumLimit(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(103)
|
||||
tunnelID := int64(1)
|
||||
|
||||
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(?, 'num_ok_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 3, ?, ?, 1)
|
||||
`, userID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'num_ok_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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, ?, ?, NULL, 99999, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(1, ?, 'num_ok_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 1: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "num_ok_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"new_forward_ok","remoteAddr":"1.1.1.1:53"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
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 success (code=0) when below num limit, got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateAllowedWhenNumZero(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
userID := int64(104)
|
||||
tunnelID := int64(1)
|
||||
|
||||
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(?, 'num_zero_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 0, ?, ?, 1)
|
||||
`, userID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, 'num_zero_tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||
`, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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, ?, ?, NULL, 0, 99999, 0, 0, 1, 2727251700000, 1)
|
||||
`, userID, tunnelID).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel with num=0: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(1, ?, 'num_zero_user', 'existing_forward_1', ?, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 1: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.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(2, ?, 'num_zero_user', 'existing_forward_2', ?, '8.8.4.4:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, userID, tunnelID, now, now).Error; err != nil {
|
||||
t.Fatalf("insert existing forward 2: %v", err)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(userID, "num_zero_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
|
||||
payload := `{"tunnelId":1,"name":"new_forward_zero","remoteAddr":"1.1.1.1:53"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
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 success (code=0) when num=0 (unlimited), got code=%d msg=%q", out.Code, out.Msg)
|
||||
}
|
||||
}
|
||||
@@ -624,6 +624,11 @@ func resumeService(ctx *gin.Context) {
|
||||
existingSvc.Close()
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
@@ -1039,8 +1044,13 @@ func resumeServices(ctx *gin.Context) {
|
||||
str.service.Close()
|
||||
registry.ServiceRegistry().Unregister(str.name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if str.serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(str.serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 重新解析并启动服务
|
||||
svc, err := parser.ParseService(str.serviceConfig)
|
||||
|
||||
@@ -397,8 +397,13 @@ func resumeServices(req resumeServicesRequest) error {
|
||||
str.service.Close()
|
||||
registry.ServiceRegistry().Unregister(str.name)
|
||||
|
||||
// 强制断开端口的所有连接
|
||||
if str.serviceConfig.Addr != "" {
|
||||
_ = kill.ForceClosePortConnections(str.serviceConfig.Addr)
|
||||
}
|
||||
|
||||
// 等待端口释放
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 重新解析并启动服务
|
||||
svc, err := parser.ParseService(str.serviceConfig)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
@@ -1220,6 +1221,30 @@ export default function ConfigPage() {
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Floating Save Button (FAB) */}
|
||||
<AnimatePresence>
|
||||
{hasChanges && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="fixed bottom-6 right-6 z-50"
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color="primary"
|
||||
size="lg"
|
||||
className="w-12 h-12 rounded-full shadow-lg"
|
||||
isLoading={saving}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{!saving && <SaveIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user