diff --git a/AGENTS.md b/AGENTS.md index d88d47d..6131811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a | `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry | ## CONVENTIONS +- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch. - **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`. - **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module. - **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK. diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 9b4447c..1a6eeb7 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -261,6 +261,9 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) { defaultString(asString(req["port"]), "1000-65535"), nullableText(asString(req["interfaceName"])), nullableText(""), + nullableText(strings.TrimSpace(asString(req["remark"]))), + nullableText(strings.TrimSpace(asString(req["tags"]))), + nullableUnixMilli(asInt64(req["expiryTime"], 0)), asInt(req["http"], 0), asInt(req["tls"], 0), asInt(req["socks"], 0), @@ -326,6 +329,9 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { defaultString(asString(req["port"]), "1000-65535"), nullableText(asString(req["interfaceName"])), nullableText(asString(req["extraIPs"])), + nullableText(strings.TrimSpace(asString(req["remark"]))), + nullableText(strings.TrimSpace(asString(req["tags"]))), + nullableUnixMilli(asInt64(req["expiryTime"], 0)), newHTTP, newTLS, newSocks, @@ -3534,6 +3540,13 @@ func nullableText(s string) interface{} { return s } +func nullableUnixMilli(v int64) interface{} { + if v <= 0 { + return nil + } + return v +} + func nullableInt(v *int64) interface{} { if v == nil { return nil diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index 8eb51a6..d69a6e1 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -60,6 +60,9 @@ func (ForwardPort) TableName() string { return "forward_port" } type Node struct { ID int64 `gorm:"primaryKey;autoIncrement"` Name string `gorm:"type:varchar(100);not null"` + Remark sql.NullString `gorm:"column:remark;type:text"` + Tags sql.NullString `gorm:"column:tags;type:text"` + ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"` Secret string `gorm:"type:varchar(100);not null"` ServerIP string `gorm:"column:server_ip;type:varchar(100);not null"` ServerIPV4 sql.NullString `gorm:"column:server_ip_v4;type:varchar(100)"` @@ -336,6 +339,9 @@ type UserBackup struct { type NodeBackup struct { ID int64 `json:"id"` Name string `json:"name"` + Remark string `json:"remark,omitempty"` + Tags string `json:"tags,omitempty"` + ExpiryTime int64 `json:"expiryTime,omitempty"` Secret string `json:"secret"` ServerIP string `json:"serverIp"` ServerIPv4 string `json:"serverIpV4,omitempty"` diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index c2bff03..e0b9949 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -634,7 +634,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) { for _, n := range nodes { items = append(items, map[string]interface{}{ "id": n.ID, "inx": n.Inx, "name": n.Name, - "ip": n.ServerIP, "serverIp": n.ServerIP, + "remark": nullableString(n.Remark), + "tags": nullableString(n.Tags), + "expiryTime": nullableInt64(n.ExpiryTime), + "ip": n.ServerIP, "serverIp": n.ServerIP, "serverIpV4": nullableString(n.ServerIPV4), "serverIpV6": nullableString(n.ServerIPV6), "extraIPs": nullableString(n.ExtraIPs), @@ -700,25 +703,26 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) { } type fwdRow struct { - ID int64 - UserID int64 - UserName string - Name string - TunnelID int64 - TunnelName string - RemoteAddr string - Strategy string - InFlow int64 - OutFlow int64 - CreatedTime int64 - Status int - Inx int - SpeedID sql.NullInt64 + ID int64 + UserID int64 + UserName string + Name string + TunnelID int64 + TunnelName string + TrafficRatio float64 + RemoteAddr string + Strategy string + InFlow int64 + OutFlow int64 + 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, forward.speed_id"). + Select("forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, COALESCE(tunnel.traffic_ratio, 1.0) AS traffic_ratio, 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 @@ -735,7 +739,8 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) { 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), + "tunnelTrafficRatio": row.TrafficRatio, + "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), @@ -1814,11 +1819,15 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) { for _, n := range nodes { b := model.NodeBackup{ ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP, + Remark: n.Remark.String, Tags: n.Tags.String, Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks, CreatedTime: n.CreatedTime, Status: n.Status, TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr, Inx: n.Inx, IsRemote: n.IsRemote, } + if n.ExpiryTime.Valid { + b.ExpiryTime = n.ExpiryTime.Int64 + } if n.UpdatedTime.Valid { b.UpdatedTime = n.UpdatedTime.Int64 } @@ -2168,6 +2177,9 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) item := model.Node{ ID: n.ID, Name: n.Name, + Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""}, + Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""}, + ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0}, Secret: n.Secret, ServerIP: n.ServerIP, ServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true}, @@ -2192,7 +2204,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ - "name", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", + "name", "remark", "tags", "expiry_time", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", "http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr", "inx", "is_remote", "remote_url", "remote_token", "remote_config", }), diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index ad29f59..985b08f 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -196,12 +196,15 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil } -func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error { +func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } node := model.Node{ Name: name, + Remark: nullStringFromInterface(remark), + Tags: nullStringFromInterface(tags), + ExpiryTime: nullInt64FromInterface(expiryTime), Secret: secret, ServerIP: serverIP, ServerIPV4: nullStringFromInterface(serverIPV4), @@ -239,7 +242,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla return node.Status, node.HTTP, node.TLS, node.Socks, nil } -func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error { +func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } @@ -247,6 +250,9 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser Where("id = ?", id). Updates(map[string]interface{}{ "name": name, + "remark": nullStringFromInterface(remark), + "tags": nullStringFromInterface(tags), + "expiry_time": nullInt64FromInterface(expiryTime), "server_ip": serverIP, "server_ip_v4": nullStringFromInterface(serverIPV4), "server_ip_v6": nullStringFromInterface(serverIPV6), diff --git a/go-backend/tests/contract/forward_contract_test.go b/go-backend/tests/contract/forward_contract_test.go index 34d5199..2bf72fc 100644 --- a/go-backend/tests/contract/forward_contract_test.go +++ b/go-backend/tests/contract/forward_contract_test.go @@ -31,7 +31,7 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) { if err := repo.DB().Exec(` INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, "contract-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + `, "contract-tunnel", 2.5, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { t.Fatalf("insert tunnel: %v", err) } tunnelID := mustLastInsertID(t, repo, "contract-tunnel") @@ -118,6 +118,13 @@ func TestForwardOwnershipAndScopeContracts(t *testing.T) { if got := int64(idFloat); got != userForwardID { t.Fatalf("expected forward id %d, got %d", userForwardID, got) } + ratioFloat, ok := item["tunnelTrafficRatio"].(float64) + if !ok { + t.Fatalf("expected tunnelTrafficRatio to be float64, got %T", item["tunnelTrafficRatio"]) + } + if ratioFloat != 2.5 { + t.Fatalf("expected tunnelTrafficRatio 2.5, got %v", ratioFloat) + } }) t.Run("forward diagnose returns structured payload", func(t *testing.T) { diff --git a/plans/020-ajax-no-refresh-ux.md b/plans/020-ajax-no-refresh-ux.md new file mode 100644 index 0000000..74a3dca --- /dev/null +++ b/plans/020-ajax-no-refresh-ux.md @@ -0,0 +1,164 @@ +# 020 AJAX No-refresh UX + +## Objective + +- Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite. +- Keep the existing `axios + local React state + custom hooks` architecture, and extend it with polling, realtime hardening, and local state patching where it improves responsiveness. +- Deliver the work in phases so the highest-value improvements ship first: dashboard auto-refresh and node realtime resilience, then local list updates after mutations, then batch progress and search/filter polish. + +## Non-goals + +- Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue. +- Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads. +- Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface. +- Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`. + +## Current State + +- `vite-frontend/src/pages/node/use-node-realtime.ts` and `vite-frontend/src/pages/node.tsx` already provide websocket-driven node status, system info, and upgrade progress updates. +- `vite-frontend/src/pages/forward.tsx`, `vite-frontend/src/pages/tunnel.tsx`, `vite-frontend/src/pages/user.tsx`, and `vite-frontend/src/pages/node.tsx` already submit forms asynchronously, so the main remaining gap is consistency of post-submit local refresh behavior. +- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` currently fetches dashboard data only once on mount, so traffic charts and counters do not auto-refresh. +- Several mutation handlers still rely on page-level reload functions such as `loadData()`, `loadUsers()`, or `loadNodes()` instead of patching only the changed records. +- Batch progress UI exists for node upgrade but not for other batch actions such as forward and tunnel operations. + +## Design Principles + +- Prefer local state patching after successful mutations when the changed record set is known. +- Prefer targeted refetches over full-page refetches when the server is the source of truth for a small dependent dataset. +- Use polling only where realtime transport does not already exist. +- Pause or reduce background refresh work when the page is hidden to avoid unnecessary traffic. +- Keep UI feedback explicit: loading states, toast feedback, and visible progress for long-running batch actions. + +## Checklist + +- [x] Refactor dashboard data loading into reusable refresh callbacks in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`. +- [x] Add dashboard traffic polling with visibility-aware pause/resume and safe notification deduplication. +- [x] Harden node realtime reconnection behavior in `vite-frontend/src/pages/node/use-node-realtime.ts` and define a fallback refresh path if websocket recovery fails. +- [x] Add shared local-list patch helpers for replace/remove/upsert patterns used by page-level mutation handlers. +- [x] Convert forward create/edit/delete/service-toggle flows in `vite-frontend/src/pages/forward.tsx` from whole-page refetches to local or targeted updates where safe. +- [x] Convert tunnel create/edit/delete flows in `vite-frontend/src/pages/tunnel.tsx` from whole-page refetches to local or targeted updates where safe. +- [x] Convert user create/edit/delete and user-tunnel permission mutation flows in `vite-frontend/src/pages/user.tsx` to local or targeted updates where safe. +- [x] Extend batch action UX to show visible progress or staged feedback for forward and tunnel batch operations. +- [x] Normalize search/filter behavior and document where client-side instant filtering is appropriate versus where server-side pagination must remain authoritative. +- [ ] Run focused frontend verification and record the result in this plan after implementation. + +## Implementation Plan + +### Phase 1 - Dashboard auto-refresh and node realtime resilience + +#### 1. Dashboard traffic/statistics auto-refresh + +- Extract `loadPackageData()` and `loadAnnouncement()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` into stable callbacks so the hook can refresh data without re-running the whole mount sequence. +- Add a 5-second polling loop for package, flow, and chart data returned by `getUserPackageInfo()`. +- Keep announcement loading low-frequency or first-load only unless the API contract clearly expects live updates. +- Pause polling when `document.visibilityState !== "visible"`, then trigger an immediate refresh when the tab becomes visible again. +- Preserve current loading UX for first load, but use a silent refresh path for polling so the page does not flicker. + +#### 2. Dashboard notification safety + +- Audit `checkExpirationNotifications()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` so polling does not repeatedly emit expiration warnings. +- Continue using notification deduplication, but base it on stable expiration identifiers rather than every poll cycle. +- Ensure refreshes that only change traffic counters do not retrigger expiry toasts. + +#### 3. Node realtime hardening + +- Review `vite-frontend/src/pages/node/use-node-realtime.ts` reconnect logic, which currently stops after a fixed retry budget. +- Replace the hard stop with controlled backoff reconnect behavior, or explicitly trigger a degraded polling fallback once retry exhaustion is reached. +- If a fallback list refresh is introduced, merge incoming node metadata with existing `systemInfo`, `connectionStatus`, and upgrade-progress state so live metrics are not wiped during recovery. +- Keep the existing offline debounce behavior in `vite-frontend/src/pages/node/use-node-offline-timers.ts`. + +### Phase 2 - Local mutation updates and partial refreshes + +#### 4. Shared list-patching helpers + +- Add small reusable helpers for common state operations such as: + - replace one item by `id` + - remove one or many items by `id` + - upsert a created or updated item into an ordered list + - preserve derived UI-only fields during server payload merges +- Keep these helpers local to the frontend codebase and avoid introducing a generic state-management abstraction. + +#### 5. Forward page partial refresh conversion + +- Target `vite-frontend/src/pages/forward.tsx` mutation handlers first because the page already contains some optimistic/local patterns. +- Preserve the current local behavior for service toggles, but review rollback handling so final UI state matches backend truth after success or failure. +- Change create/edit/delete flows to patch `forwards` state directly when the response payload is sufficient. +- Use targeted refetches only when an operation changes dependent datasets that are not reliably derivable from the local page state. +- Re-check grouped ordering, collapsed-state persistence, and selected-row state after local mutations. + +#### 6. Tunnel page partial refresh conversion + +- Update `vite-frontend/src/pages/tunnel.tsx` so create/edit/delete mutate `tunnels` state directly instead of always calling `loadData()`. +- Keep node reference data refresh separate from tunnel list refresh so a tunnel mutation does not force a full page data reload. +- Preserve existing drag-sort behavior and ensure local patching keeps `inx` and stored order consistent. + +#### 7. User page partial refresh conversion + +- Update `vite-frontend/src/pages/user.tsx` so create/edit/delete patch the `users` list when the current page can be updated safely. +- Update user-tunnel permission flows to patch `userTunnels` directly after assign, edit, remove, and flow-reset operations. +- Respect server-side pagination semantics for the user list; if the server response does not provide enough data for a safe local patch, use a targeted page refetch rather than a full multi-dataset refresh. +- Keep current modal and toast behavior unchanged unless the local update path exposes stale-state issues. + +### Phase 3 - Batch progress UX and search/filter polish + +#### 8. Batch progress UX + +- Use the node upgrade progress model in `vite-frontend/src/pages/node.tsx` as the UI reference for long-running operations. +- Review `vite-frontend/src/pages/forward/batch-actions.ts` and tunnel batch handlers to determine whether current APIs expose enough intermediate state for real progress. +- If only final summary APIs are available, implement staged client-side progress feedback such as `processing X/Y`, current action label, success count, and failure count. +- If the UX requirement cannot be met without backend support, document the missing backend contract and split the work into frontend and backend follow-ups. + +#### 9. Search and filter responsiveness + +- Preserve instant client-side filtering on pages that already hold the authoritative dataset locally, including node, tunnel, and forward pages. +- Audit the user page separately because it depends on server-side pagination and keyword search. +- If user-page instant filtering is desired, choose one of two explicit strategies: + - keep server-side pagination authoritative and add debounce for keyword-triggered requests, or + - load a larger local dataset only if product requirements accept the cost. +- Do not silently mix partial client filtering with incomplete paginated datasets. + +## Risks and Mitigations + +- Repeated dashboard polling may spam expiry toasts. + - Mitigation: deduplicate notifications based on expiration identity and only emit on meaningful state changes. +- Node recovery refreshes may wipe websocket-derived metrics. + - Mitigation: merge fetched node metadata into existing live state instead of replacing the whole record blindly. +- Local mutation patching may desynchronize grouped, sorted, or selected views. + - Mitigation: patch canonical source arrays first, then recompute derived memoized groupings from state. +- Batch APIs may not expose progress details. + - Mitigation: implement client-side staged progress where possible and document backend gaps where not. +- User-page local updates may conflict with pagination semantics. + - Mitigation: prefer targeted page refetch over unsafe optimistic filtering or cross-page list mutation. + +## Verification Plan + +- Dashboard: + - Open `dashboard` and confirm traffic counters and chart data refresh at least once every 5 seconds without manual reload. + - Confirm hidden-tab pause and visible-tab immediate refresh behavior. + - Confirm expiry toasts do not repeat on every polling cycle. +- Nodes: + - Confirm websocket-driven online/offline transitions still work. + - Simulate websocket interruption and verify reconnect or fallback refresh behavior. + - Confirm recovery does not clear existing live metrics unexpectedly. +- Forwards, tunnels, users: + - Create, edit, delete, enable, disable, and reset flows without browser reload. + - Confirm the affected rows update immediately and other unrelated rows stay stable. + - Confirm selection state, ordering, and modal close behavior remain correct after local patching. +- Batch actions: + - Confirm visible progress or staged status feedback exists during long-running operations. + - Confirm success and failure summaries remain accurate after completion. +- Build: + - Run `cd vite-frontend && npm run build`. + +## Rollout Notes + +- Ship Phase 1 first because it matches the issue approval priority and provides the clearest user-visible gain. +- Keep each phase in reviewable commits so regressions in local list patching can be isolated quickly. +- If backend support becomes necessary for real batch progress, land the frontend scaffolding separately and track the backend dependency explicitly. + +## Test Record + +- Command: `cd vite-frontend && npm install` +- Result: passed. +- Command: `cd vite-frontend && npm run build` +- Result: passed. diff --git a/plans/021-node-remarks-tags-expiry.md b/plans/021-node-remarks-tags-expiry.md new file mode 100644 index 0000000..af0b3a6 --- /dev/null +++ b/plans/021-node-remarks-tags-expiry.md @@ -0,0 +1,6 @@ +# Node Remarks, Tags, and Expiry Plan + +- [x] Review issue #246 and inspect current node backend/frontend flow +- [x] Extend node persistence and API payloads with remark, tags, and expiry fields +- [x] Update node management UI to edit, display, and search the new metadata +- [x] Verify the backend and frontend still build successfully diff --git a/plans/022-node-expiry-highlights-dashboard-reminders.md b/plans/022-node-expiry-highlights-dashboard-reminders.md new file mode 100644 index 0000000..81b9a15 --- /dev/null +++ b/plans/022-node-expiry-highlights-dashboard-reminders.md @@ -0,0 +1,6 @@ +# Node Expiry Highlights And Dashboard Reminders Plan + +- [x] Review current node page and dashboard data flow for expiry-related hooks +- [x] Add node expiry status helpers plus expiring-soon filter/highlight in node management +- [x] Load node expiry data on the dashboard for admins and render reminder card +- [x] Verify frontend build and mark the plan complete diff --git a/plans/023-forward-page-tunnel-traffic-ratio.md b/plans/023-forward-page-tunnel-traffic-ratio.md new file mode 100644 index 0000000..bbda8ce --- /dev/null +++ b/plans/023-forward-page-tunnel-traffic-ratio.md @@ -0,0 +1,6 @@ +# Forward Page Tunnel Traffic Ratio Plan + +- [x] Review `/forward/list` data flow and rule page render points for tunnel ratio support +- [x] Extend backend forward list payload with tunnel traffic ratio and cover it with a contract test +- [x] Update forward page types, mapping, grouped metadata, and visible ratio UI across list modes +- [x] Verify targeted backend tests and frontend build, then mark the plan complete diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 305d549..acddbec 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -65,6 +65,8 @@ export const getUserPackageInfo = () => export const createNode = (data: NodeMutationPayload) => Network.post("/node/create", data); export const getNodeList = () => Network.post("/node/list"); +export const getDashboardNodeExpiryList = () => + Network.post("/node/list", {}); export const updateNode = (data: NodeMutationPayload) => Network.post("/node/update", data); export const deleteNode = (id: number) => Network.post("/node/delete", { id }); diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 94f817c..9fd44e0 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -3,6 +3,9 @@ export interface NodeApiItem { name: string; status: number; inx?: number; + remark?: string; + tags?: string; + expiryTime?: number; syncError?: string; [key: string]: unknown; } @@ -44,6 +47,7 @@ export interface ForwardApiItem { name: string; status: number; tunnelName?: string; + tunnelTrafficRatio?: number; inIp?: string; inPort?: number; remoteAddr?: string; @@ -214,6 +218,9 @@ export interface NodeMutationPayload { name?: string; status?: number; inx?: number; + remark?: string; + tags?: string; + expiryTime?: number; serverIp?: string; serverIpV4?: string; serverIpV6?: string; diff --git a/vite-frontend/src/pages/dashboard.tsx b/vite-frontend/src/pages/dashboard.tsx index 889a481..f6f15e3 100644 --- a/vite-frontend/src/pages/dashboard.tsx +++ b/vite-frontend/src/pages/dashboard.tsx @@ -17,6 +17,7 @@ import { MetricCard } from "@/pages/dashboard/components/metric-card"; import { useDashboardData, type DashboardForward as Forward, + type DashboardNodeExpiryItem, type DashboardUserTunnel as UserTunnel, } from "@/pages/dashboard/use-dashboard-data"; @@ -34,6 +35,7 @@ export default function DashboardPage() { userTunnels, forwardList, statisticsFlows, + nodeExpiryReminders, isAdmin, announcement, } = useDashboardData(); @@ -70,6 +72,103 @@ export default function DashboardPage() { return value.toString(); }; + const getNodeExpiryStatus = (expiryTime?: number) => { + if (!expiryTime) { + return { + label: "永久有效", + badgeClassName: + "bg-default-100 text-default-700 dark:bg-default-50 dark:text-default-300", + }; + } + + const diffMs = expiryTime - Date.now(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays <= 0) { + return { + label: "已过期", + badgeClassName: + "bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300", + }; + } + + if (diffDays === 1) { + return { + label: "明天到期", + badgeClassName: + "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300", + }; + } + + return { + label: `${diffDays}天后到期`, + badgeClassName: + diffDays <= 7 + ? "bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300" + : "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300", + }; + }; + + const parseNodeTags = (tags?: string) => { + if (!tags) return []; + return tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + .slice(0, 3); + }; + + const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => { + const expiryStatus = getNodeExpiryStatus(node.expiryTime); + const tags = parseNodeTags(node.tags); + + return ( +
+
+
+
+ {node.name} +
+
节点 ID: {node.id}
+
+ + {expiryStatus.label} + +
+ +
+ {node.expiryTime + ? new Date(node.expiryTime).toLocaleString() + : "未设置到期时间"} +
+ + {node.remark?.trim() && ( +

+ {node.remark.trim()} +

+ )} + + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ ); + }; + // 处理24小时流量统计数据 const processFlowChartData = () => { // 生成最近24小时的时间数组(从当前小时往前推24小时) @@ -661,6 +760,52 @@ export default function DashboardPage() { statisticsFlowsCount={statisticsFlows.length} /> + {isAdmin && nodeExpiryReminders.length > 0 && ( + + +
+
+
+ +
+
+

+ 节点到期提醒 +

+

+ 展示 7 天内到期或已过期的节点,便于提前续费或清理 +

+
+
+ + {nodeExpiryReminders.length} 个提醒 + +
+
+ +
+ {nodeExpiryReminders.slice(0, 6).map(renderNodeExpiryCard)} +
+ {nodeExpiryReminders.length > 6 && ( +

+ 还有 {nodeExpiryReminders.length - 6} 个节点未展开显示,可前往节点页面继续处理。 +

+ )} +
+
+ )} + {/* 隧道权限 - 管理员不显示 */} {!isAdmin && ( diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts index f14d231..b5d644b 100644 --- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts +++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts @@ -1,10 +1,11 @@ -import type { ForwardApiItem } from "@/api/types"; +import type { ForwardApiItem, NodeApiItem } from "@/api/types"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import { getAnnouncement, + getDashboardNodeExpiryList, getUserPackageInfo, type AnnouncementData, } from "@/api"; @@ -52,12 +53,38 @@ export interface DashboardStatisticsFlow { time: string; } +export interface DashboardNodeExpiryItem { + id: number; + name: string; + remark?: string; + tags?: string; + expiryTime?: number; +} + +const DASHBOARD_POLL_INTERVAL_MS = 5000; +const EXPIRATION_NOTIFICATION_STORAGE_KEY = + "dashboard:last-expiration-notification"; + +const buildExpirationNotificationKey = ( + userInfo: DashboardUserInfo, + tunnels: DashboardUserTunnel[], +) => { + const userExpTime = userInfo.expTime ?? "permanent"; + const tunnelExpirationKey = [...tunnels] + .map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? "permanent"}`) + .sort() + .join("|"); + + return `user:${userExpTime};tunnels:${tunnelExpirationKey}`; +}; + interface DashboardDataState { loading: boolean; userInfo: DashboardUserInfo; userTunnels: DashboardUserTunnel[]; forwardList: DashboardForward[]; statisticsFlows: DashboardStatisticsFlow[]; + nodeExpiryReminders: DashboardNodeExpiryItem[]; isAdmin: boolean; announcement: AnnouncementData | null; } @@ -66,8 +93,10 @@ const checkExpirationNotifications = ( userInfo: DashboardUserInfo, tunnels: DashboardUserTunnel[], ) => { - const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`; - const lastNotified = localStorage.getItem("lastNotified"); + const notificationKey = buildExpirationNotificationKey(userInfo, tunnels); + const lastNotified = localStorage.getItem( + EXPIRATION_NOTIFICATION_STORAGE_KEY, + ); if (lastNotified === notificationKey) { return; @@ -148,7 +177,7 @@ const checkExpirationNotifications = ( }); if (hasNotification) { - localStorage.setItem("lastNotified", notificationKey); + localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey); } }; @@ -174,6 +203,28 @@ const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => { })); }; +const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => { + const now = Date.now(); + const warningWindowMs = 7 * 24 * 60 * 60 * 1000; + + return (items || []) + .map((item) => ({ + id: item.id, + name: item.name || "", + remark: typeof item.remark === "string" ? item.remark : "", + tags: typeof item.tags === "string" ? item.tags : "", + expiryTime: + typeof item.expiryTime === "number" && item.expiryTime > 0 + ? item.expiryTime + : undefined, + })) + .filter((item) => { + if (!item.expiryTime) return false; + return item.expiryTime <= now + warningWindowMs; + }) + .sort((a, b) => (a.expiryTime || 0) - (b.expiryTime || 0)); +}; + export const useDashboardData = (): DashboardDataState => { const [loading, setLoading] = useState(true); const [userInfo, setUserInfo] = useState( @@ -184,64 +235,171 @@ export const useDashboardData = (): DashboardDataState => { const [statisticsFlows, setStatisticsFlows] = useState< DashboardStatisticsFlow[] >([]); + const [nodeExpiryReminders, setNodeExpiryReminders] = useState< + DashboardNodeExpiryItem[] + >([]); const [isAdmin, setIsAdmin] = useState(false); const [announcement, setAnnouncement] = useState( null, ); + const isMountedRef = useRef(true); + const packageRequestInFlightRef = useRef(false); + const nodeExpiryRequestInFlightRef = useRef(false); - useEffect(() => { - const loadAnnouncement = async () => { - try { - const res = await getAnnouncement(); + const applyPackageData = useCallback((data: { + userInfo?: DashboardUserInfo; + tunnelPermissions?: DashboardUserTunnel[]; + forwards?: ForwardApiItem[]; + statisticsFlows?: DashboardStatisticsFlow[]; + }) => { + const normalizedTunnelPermissions = normalizeTunnelPermissions( + data.tunnelPermissions || [], + ); + const normalizedForwards = normalizeForwards(data.forwards || []); - if (res.code === 0 && res.data && res.data.enabled === 1) { - setAnnouncement(res.data); - } - } catch {} - }; + if (!isMountedRef.current) { + return; + } + + setUserInfo(data.userInfo || ({} as DashboardUserInfo)); + setUserTunnels(normalizedTunnelPermissions); + setForwardList(normalizedForwards); + setStatisticsFlows(data.statisticsFlows || []); + + checkExpirationNotifications( + data.userInfo || ({} as DashboardUserInfo), + normalizedTunnelPermissions, + ); + }, []); + + const loadPackageData = useCallback( + async ({ silent = false, notifyOnError = false } = {}) => { + if (packageRequestInFlightRef.current) { + return; + } + + packageRequestInFlightRef.current = true; + + if (!silent && isMountedRef.current) { + setLoading(true); + } - const loadPackageData = async () => { - setLoading(true); try { const res = await getUserPackageInfo(); if (res.code === 0) { - const data = res.data; - const normalizedTunnelPermissions = normalizeTunnelPermissions( - data.tunnelPermissions || [], - ); - const normalizedForwards = normalizeForwards(data.forwards || []); - - setUserInfo(data.userInfo || ({} as DashboardUserInfo)); - setUserTunnels(normalizedTunnelPermissions); - setForwardList(normalizedForwards); - setStatisticsFlows(data.statisticsFlows || []); - - checkExpirationNotifications( - data.userInfo, - normalizedTunnelPermissions, - ); - } else { + applyPackageData(res.data || {}); + } else if (notifyOnError) { toast.error(res.msg || "获取套餐信息失败"); } } catch { - toast.error("获取套餐信息失败"); + if (notifyOnError) { + toast.error("获取套餐信息失败"); + } } finally { - setLoading(false); + packageRequestInFlightRef.current = false; + + if (!silent && isMountedRef.current) { + setLoading(false); + } + } + }, + [applyPackageData], + ); + + const loadAnnouncement = useCallback(async () => { + try { + const res = await getAnnouncement(); + + if (!isMountedRef.current) { + return; + } + + if (res.code === 0 && res.data && res.data.enabled === 1) { + setAnnouncement(res.data); + } else { + setAnnouncement(null); + } + } catch { + if (isMountedRef.current) { + setAnnouncement(null); + } + } + }, []); + + const loadNodeExpiryData = useCallback(async () => { + if (nodeExpiryRequestInFlightRef.current) { + return; + } + + nodeExpiryRequestInFlightRef.current = true; + + try { + const res = await getDashboardNodeExpiryList(); + + if (!isMountedRef.current) { + return; + } + + if (res.code === 0 && Array.isArray(res.data)) { + setNodeExpiryReminders(normalizeNodeExpiryReminders(res.data)); + } + } catch { + } finally { + nodeExpiryRequestInFlightRef.current = false; + } + }, []); + + useEffect(() => { + isMountedRef.current = true; + const adminFlag = getAdminFlag(); + + setIsAdmin(adminFlag); + + void loadPackageData({ notifyOnError: true }); + void loadAnnouncement(); + if (adminFlag) { + void loadNodeExpiryData(); + } + localStorage.setItem("e", "/dashboard"); + + return () => { + isMountedRef.current = false; + }; + }, [loadAnnouncement, loadNodeExpiryData, loadPackageData]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + void loadPackageData({ silent: true }); + if (isAdmin) { + void loadNodeExpiryData(); + } } }; - setLoading(true); - setUserInfo({} as DashboardUserInfo); - setUserTunnels([]); - setForwardList([]); - setStatisticsFlows([]); - setIsAdmin(getAdminFlag()); + const interval = window.setInterval(() => { + if (document.visibilityState !== "visible") { + return; + } - loadPackageData(); - loadAnnouncement(); - localStorage.setItem("e", "/dashboard"); - }, []); + void loadPackageData({ silent: true }); + if (isAdmin) { + void loadNodeExpiryData(); + } + }, DASHBOARD_POLL_INTERVAL_MS); + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.clearInterval(interval); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [isAdmin, loadNodeExpiryData, loadPackageData]); return { loading, @@ -249,6 +407,7 @@ export const useDashboardData = (): DashboardDataState => { userTunnels, forwardList, statisticsFlows, + nodeExpiryReminders, isAdmin, announcement, }; diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index f2d3c3f..aa897fd 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -1,6 +1,6 @@ -import type { SpeedLimitApiItem } from "@/api/types"; +import type { ForwardApiItem, SpeedLimitApiItem } from "@/api/types"; -import { useState, useEffect, useMemo, useRef } from "react"; +import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import toast from "react-hot-toast"; import { DndContext, @@ -48,6 +48,7 @@ import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Spinner } from "@/shadcn-bridge/heroui/spinner"; import { Switch } from "@/shadcn-bridge/heroui/switch"; import { Alert } from "@/shadcn-bridge/heroui/alert"; +import { Progress } from "@/shadcn-bridge/heroui/progress"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { createForward, @@ -103,6 +104,7 @@ interface Forward { name: string; tunnelId: number; tunnelName: string; + tunnelTrafficRatio?: number; inIp: string; inPort: number; remoteAddr: string; @@ -163,9 +165,16 @@ interface ForwardUserGroup { interface ForwardTunnelGroup { tunnelKey: string; tunnelName: string; + tunnelTrafficRatio?: number; items: Forward[]; } +interface BatchProgressState { + active: boolean; + label: string; + percent: number; +} + type ForwardGroupOrderMap = Record; type ForwardGroupCollapsedMap = Record; @@ -238,6 +247,32 @@ const compareForwardTunnelGroupKeyAsc = (a: string, b: string): number => { return compareForwardTunnelNameAsc(a, b); }; +const normalizeTunnelTrafficRatio = (value: unknown): number => { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value); + + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + + return 1; +}; + +const formatTunnelTrafficRatio = (value?: number): string => { + const ratio = normalizeTunnelTrafficRatio(value); + + if (Number.isInteger(ratio)) { + return `${ratio}x`; + } + + return `${parseFloat(ratio.toFixed(2))}x`; +}; + const buildForwardGroupOrderLocalKey = (tokenUserId: number): string => { return `${FORWARD_GROUP_ORDER_LOCAL_STORAGE_PREFIX}:u:${tokenUserId}`; }; @@ -490,6 +525,43 @@ const isSameGroupCollapsedMap = ( return true; }; +const normalizeForwardItems = (items: Forward[]): Forward[] => { + return items.map((forward) => ({ + ...forward, + serviceRunning: forward.status === 1, + })); +}; + +const mapForwardApiItems = (items: ForwardApiItem[]): Forward[] => { + return (items || []).map((forward) => ({ + id: forward.id, + name: forward.name, + tunnelId: forward.tunnelId ?? 0, + tunnelName: forward.tunnelName || "", + tunnelTrafficRatio: normalizeTunnelTrafficRatio( + forward.tunnelTrafficRatio, + ), + inIp: forward.inIp || "", + inPort: forward.inPort ?? 0, + remoteAddr: forward.remoteAddr || "", + strategy: typeof forward.strategy === "string" ? forward.strategy : "fifo", + status: typeof forward.status === "number" ? forward.status : 0, + inFlow: forward.inFlow ?? 0, + outFlow: forward.outFlow ?? 0, + createdTime: + typeof forward.createdTime === "string" ? forward.createdTime : "", + userName: + typeof forward.userName === "string" ? forward.userName : undefined, + userId: typeof forward.userId === "number" ? forward.userId : undefined, + inx: typeof forward.inx === "number" ? forward.inx : undefined, + speedId: + typeof forward.speedId === "number" || forward.speedId === null + ? forward.speedId + : undefined, + serviceRunning: forward.status === 1, + })); +}; + export default function ForwardPage() { const [loading, setLoading] = useState(true); const [forwards, setForwards] = useState([]); @@ -603,6 +675,11 @@ export default function ForwardPage() { null, ); const [batchLoading, setBatchLoading] = useState(false); + const [batchProgress, setBatchProgress] = useState({ + active: false, + label: "", + percent: 0, + }); const [groupOrderMap, setGroupOrderMap] = useState({}); const [collapsedTunnelGroups, setCollapsedTunnelGroups] = useState({}); @@ -959,7 +1036,7 @@ export default function ForwardPage() { return Number.isFinite(shareId) && shareId > 0 ? shareId : null; }; - const mergeFederationShareFlow = async ( + const mergeFederationShareFlow = useCallback(async ( forwardsData: Forward[], ): Promise => { if (forwardsData.length === 0) { @@ -1135,7 +1212,7 @@ export default function ForwardPage() { } catch { return forwardsData; } - }; + }, []); const getForwardDisplayFlow = (forward: Forward): number => { const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0); @@ -1147,10 +1224,6 @@ export default function ForwardPage() { return forward.federationShareFlow || 0; }; - useEffect(() => { - loadData(); - }, []); - // 切换显示模式并保存到localStorage const handleViewModeChange = () => { const newMode = viewMode === "grouped" ? "direct" : "grouped"; @@ -1161,12 +1234,59 @@ export default function ForwardPage() { } catch {} }; + const applyForwardList = useCallback( + async (items: Forward[]) => { + const mergedForwards = await mergeFederationShareFlow( + normalizeForwardItems(items), + ); + + setForwards(mergedForwards); + + const currentUserId = JwtUtil.getUserIdFromToken(); + const { order, fromDatabase } = buildForwardOrder( + mergedForwards, + currentUserId, + ); + + setForwardOrder(order); + + if (fromDatabase) { + saveOrder(FORWARD_ORDER_KEY, order); + } + }, + [mergeFederationShareFlow], + ); + + const refreshForwardList = useCallback( + async (lod = true) => { + if (lod) { + setLoading(true); + } + + try { + const forwardsRes = await getForwardList(); + + if (forwardsRes.code === 0) { + await applyForwardList(mapForwardApiItems(forwardsRes.data || [])); + } else { + toast.error(forwardsRes.msg || "获取规则列表失败"); + } + } catch { + toast.error("获取规则列表失败"); + } finally { + if (lod) { + setLoading(false); + } + } + }, + [applyForwardList], + ); + // 加载所有数据 - const loadData = async (lod = true) => { + const loadData = useCallback(async (lod = true) => { setLoading(lod); try { - const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([ - getForwardList(), + const [tunnelsRes, speedLimitsRes] = await Promise.all([ userTunnel(), getSpeedLimitList(), ]); @@ -1175,32 +1295,7 @@ export default function ForwardPage() { getNodeList(), ]); - if (forwardsRes.code === 0) { - const forwardsData = - forwardsRes.data?.map((forward: any) => ({ - ...forward, - serviceRunning: forward.status === 1, - })) || []; - - const mergedForwards = await mergeFederationShareFlow(forwardsData); - - setForwards(mergedForwards); - - // 初始化拖拽排序顺序 - const currentUserId = JwtUtil.getUserIdFromToken(); - const { order, fromDatabase } = buildForwardOrder( - mergedForwards, - currentUserId, - ); - - setForwardOrder(order); - - if (fromDatabase) { - saveOrder(FORWARD_ORDER_KEY, order); - } - } else { - toast.error(forwardsRes.msg || "获取规则列表失败"); - } + await refreshForwardList(false); if (tunnelsRes.code === 0) { setTunnels(tunnelsRes.data || []); @@ -1226,7 +1321,11 @@ export default function ForwardPage() { } finally { setLoading(false); } - }; + }, [refreshForwardList]); + + useEffect(() => { + loadData(); + }, [loadData]); // 表单验证 const noLimitSpeedLimitIds = useMemo(() => { @@ -1385,7 +1484,20 @@ export default function ForwardPage() { if (res.code === 0) { toast.success("删除成功"); setDeleteModalOpen(false); - loadData(); + setForwardToDelete(null); + setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id)); + setForwardOrder((prev) => { + const next = prev.filter((id) => id !== forwardToDelete.id); + + saveOrder(FORWARD_ORDER_KEY, next); + return next; + }); + setSelectedIds((prev) => { + const next = new Set(prev); + + next.delete(forwardToDelete.id); + return next; + }); } else { // 删除失败,询问是否强制删除 const confirmed = window.confirm( @@ -1398,7 +1510,22 @@ export default function ForwardPage() { if (forceRes.code === 0) { toast.success("强制删除成功"); setDeleteModalOpen(false); - loadData(); + setForwardToDelete(null); + setForwards((prev) => + prev.filter((forward) => forward.id !== forwardToDelete.id), + ); + setForwardOrder((prev) => { + const next = prev.filter((id) => id !== forwardToDelete.id); + + saveOrder(FORWARD_ORDER_KEY, next); + return next; + }); + setSelectedIds((prev) => { + const next = new Set(prev); + + next.delete(forwardToDelete.id); + return next; + }); } else { toast.error(forceRes.msg || "强制删除失败"); } @@ -1496,7 +1623,7 @@ export default function ForwardPage() { } toast.success(isEdit ? "修改成功" : "创建成功"); setModalOpen(false); - loadData(); + await refreshForwardList(false); } else { toast.error(res.msg || "操作失败"); } @@ -2091,7 +2218,7 @@ export default function ForwardPage() { toast.success("导入执行完成"); - await loadData(false); + await refreshForwardList(false); } catch { toast.error("导入过程中发生错误"); } finally { @@ -2296,6 +2423,11 @@ export default function ForwardPage() { const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在删除 ${selectedIds.size} 项规则...`, + percent: 30, + }); try { const outcome = await executeForwardBatchDelete(Array.from(selectedIds)); @@ -2306,14 +2438,20 @@ export default function ForwardPage() { } if (outcome.shouldRefresh) { + setBatchProgress({ + active: true, + label: outcome.progressLabel || "正在刷新规则列表...", + percent: outcome.progressPercent ?? 75, + }); setSelectedIds(new Set()); setSelectMode(false); if (outcome.closeDeleteModal) { setBatchDeleteModalOpen(false); } - loadData(false); + await refreshForwardList(false); } } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -2321,6 +2459,11 @@ export default function ForwardPage() { const handleBatchToggleService = async (enable: boolean) => { if (selectedIds.size === 0) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在${enable ? "启用" : "停用"} ${selectedIds.size} 项规则...`, + percent: 30, + }); try { const outcome = await executeForwardBatchToggleService( Array.from(selectedIds), @@ -2334,11 +2477,17 @@ export default function ForwardPage() { } if (outcome.shouldRefresh) { + setBatchProgress({ + active: true, + label: outcome.progressLabel || "正在刷新规则列表...", + percent: outcome.progressPercent ?? 75, + }); setSelectedIds(new Set()); setSelectMode(false); - loadData(false); + await refreshForwardList(false); } } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -2346,6 +2495,11 @@ export default function ForwardPage() { const handleBatchRedeploy = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在重新下发 ${selectedIds.size} 项规则...`, + percent: 30, + }); try { const outcome = await executeForwardBatchRedeploy( Array.from(selectedIds), @@ -2358,11 +2512,17 @@ export default function ForwardPage() { } if (outcome.shouldRefresh) { + setBatchProgress({ + active: true, + label: outcome.progressLabel || "正在刷新规则列表...", + percent: outcome.progressPercent ?? 75, + }); setSelectedIds(new Set()); setSelectMode(false); - loadData(false); + await refreshForwardList(false); } } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -2370,6 +2530,11 @@ export default function ForwardPage() { const handleBatchChangeTunnel = async () => { if (selectedIds.size === 0 || !batchTargetTunnelId) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在为 ${selectedIds.size} 项规则切换隧道...`, + percent: 30, + }); try { const outcome = await executeForwardBatchChangeTunnel( Array.from(selectedIds), @@ -2383,6 +2548,11 @@ export default function ForwardPage() { } if (outcome.shouldRefresh) { + setBatchProgress({ + active: true, + label: outcome.progressLabel || "正在刷新规则列表...", + percent: outcome.progressPercent ?? 75, + }); setSelectedIds(new Set()); setSelectMode(false); if (outcome.closeChangeTunnelModal) { @@ -2391,9 +2561,10 @@ export default function ForwardPage() { if (outcome.resetTargetTunnel) { setBatchTargetTunnelId(null); } - loadData(false); + await refreshForwardList(false); } } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -2597,6 +2768,9 @@ export default function ForwardPage() { existingGroup.tunnelMap.set(tunnelKey, { tunnelKey, tunnelName, + tunnelTrafficRatio: normalizeTunnelTrafficRatio( + forward.tunnelTrafficRatio, + ), items: [forward], }); @@ -2611,6 +2785,15 @@ export default function ForwardPage() { ) { existingTunnelGroup.tunnelName = tunnelName; } + + if ( + normalizeTunnelTrafficRatio(existingTunnelGroup.tunnelTrafficRatio) === 1 && + normalizeTunnelTrafficRatio(forward.tunnelTrafficRatio) !== 1 + ) { + existingTunnelGroup.tunnelTrafficRatio = normalizeTunnelTrafficRatio( + forward.tunnelTrafficRatio, + ); + } }); const groups = Array.from(userGroupMap.values()).map((group) => { @@ -2787,6 +2970,14 @@ export default function ForwardPage() { {tunnel.tunnelName} + + {formatTunnelTrafficRatio(tunnel.tunnelTrafficRatio)} +
{tunnel.items.length} 条规则 @@ -3182,6 +3373,9 @@ export default function ForwardPage() { {forward.tunnelName} + + {formatTunnelTrafficRatio(forward.tunnelTrafficRatio)} + @@ -3337,7 +3531,7 @@ export default function ForwardPage() { {forward.name}

- {normalizeForwardTunnelName(forward.tunnelName)} + {`${normalizeForwardTunnelName(forward.tunnelName)} · ${formatTunnelTrafficRatio(forward.tunnelTrafficRatio)}`}

@@ -3817,6 +4011,23 @@ export default function ForwardPage() {
+ {batchProgress.active && ( +
+ + +
+ )} + {/* 根据显示模式渲染不同内容 */} {compactMode ? ( viewMode === "grouped" ? ( diff --git a/vite-frontend/src/pages/forward/batch-actions.ts b/vite-frontend/src/pages/forward/batch-actions.ts index 66af192..fc40bc0 100644 --- a/vite-frontend/src/pages/forward/batch-actions.ts +++ b/vite-frontend/src/pages/forward/batch-actions.ts @@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome { toastVariant: "success" | "error"; toastMessage: string; shouldRefresh: boolean; + progressPercent?: number; + progressLabel?: string; closeDeleteModal?: boolean; closeChangeTunnelModal?: boolean; resetTargetTunnel?: boolean; @@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async ( return { ...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`), shouldRefresh: true, + progressPercent: 100, + progressLabel: `删除完成:成功 ${summary.successCount} 项`, closeDeleteModal: true, }; } catch (error) { @@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async ( : `成功停用 ${summary.successCount} 项`, ), shouldRefresh: true, + progressPercent: 100, + progressLabel: `${enable ? "启用" : "停用"}完成:成功 ${summary.successCount} 项`, }; } catch (error) { return { @@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async ( return { ...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`), shouldRefresh: true, + progressPercent: 100, + progressLabel: `重新下发完成:成功 ${summary.successCount} 项`, }; } catch (error) { return { @@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async ( return { ...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`), shouldRefresh: true, + progressPercent: 100, + progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`, closeChangeTunnelModal: true, resetTargetTunnel: true, }; diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index fc78251..d18be2d 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -65,10 +65,15 @@ import { useNodeRealtime } from "@/pages/node/use-node-realtime"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; +const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000; + interface Node { id: number; inx?: number; name: string; + remark?: string; + tags?: string; + expiryTime?: number; ip: string; serverIp: string; serverIpV4?: string; @@ -103,6 +108,9 @@ interface Node { interface NodeForm { id: number | null; name: string; + remark: string; + tags: string; + expiryTime: number; serverHost: string; serverIpV4: string; serverIpV6: string; @@ -116,6 +124,95 @@ interface NodeForm { socks: number; // 0 关 1 开 } +const formatNodeExpiry = (timestamp?: number): string => { + if (!timestamp || timestamp <= 0) return "永久有效"; + return new Date(timestamp).toLocaleString(); +}; + +const EXPIRING_SOON_DAYS = 7; + +type NodeExpiryState = "permanent" | "healthy" | "expiringSoon" | "expired"; + +type NodeFilterMode = "all" | "expiringSoon" | "expired" | "withExpiry"; + +const getNodeExpiryMeta = (timestamp?: number) => { + if (!timestamp || timestamp <= 0) { + return { + state: "permanent" as NodeExpiryState, + label: "永久有效", + tone: "default" as const, + accentClassName: "", + bannerClassName: "", + isHighlighted: false, + sortWeight: 3, + }; + } + + const diffMs = timestamp - Date.now(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays <= 0) { + return { + state: "expired" as NodeExpiryState, + label: "已过期", + tone: "danger" as const, + accentClassName: + "border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20", + bannerClassName: + "bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300", + isHighlighted: true, + sortWeight: 0, + }; + } + + if (diffDays <= EXPIRING_SOON_DAYS) { + return { + state: "expiringSoon" as NodeExpiryState, + label: diffDays === 1 ? "明天到期" : `${diffDays}天后到期`, + tone: "warning" as const, + accentClassName: + "border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20", + bannerClassName: + "bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300", + isHighlighted: true, + sortWeight: 1, + }; + } + + return { + state: "healthy" as NodeExpiryState, + label: `${diffDays}天后到期`, + tone: "success" as const, + accentClassName: "", + bannerClassName: "", + isHighlighted: false, + sortWeight: 2, + }; +}; + +const normalizeNodeTags = (tags?: string): string[] => { + if (!tags) return []; + return tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); +}; + +const mergeNodeRealtimeState = ( + incomingNode: Node, + existingNode?: Node, +): Node => { + return { + ...incomingNode, + systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null, + copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false, + upgradeLoading: + existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false, + rollbackLoading: + existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false, + }; +}; + const SortableItem = ({ id, children, @@ -165,6 +262,10 @@ export default function NodePage() { "node-search-keyword", "", ); + const [nodeFilterMode, setNodeFilterMode] = useLocalStorageState( + "node-expiry-filter-mode", + "all", + ); const [isSearchVisible, setIsSearchVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); @@ -178,6 +279,9 @@ export default function NodePage() { const [form, setForm] = useState({ id: null, name: "", + remark: "", + tags: "", + expiryTime: 0, serverHost: "", serverIpV4: "", serverIpV6: "", @@ -251,8 +355,13 @@ export default function NodePage() { }); // 加载节点列表 - const loadNodes = useCallback(async () => { - setLoading(true); + const loadNodes = useCallback(async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + + if (!silent) { + setLoading(true); + } + try { const res = await getNodeList(); @@ -270,7 +379,13 @@ export default function NodePage() { copyLoading: false, })); - setNodeList(nodesData); + setNodeList((prev) => { + const previousById = new Map(prev.map((node) => [node.id, node])); + + return nodesData.map((node) => + mergeNodeRealtimeState(node, previousById.get(node.id)), + ); + }); // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序 const hasDbOrdering = nodesData.some( @@ -292,12 +407,18 @@ export default function NodePage() { ); } } else { - toast.error(res.msg || "加载节点列表失败"); + if (!silent) { + toast.error(res.msg || "加载节点列表失败"); + } } } catch { - toast.error("网络错误,请重试"); + if (!silent) { + toast.error("网络错误,请重试"); + } } finally { - setLoading(false); + if (!silent) { + setLoading(false); + } } }, []); @@ -370,7 +491,7 @@ export default function NodePage() { } }; - const { wsConnected, wsConnecting } = useNodeRealtime({ + const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({ onMessage: handleWebSocketMessage, }); @@ -378,6 +499,22 @@ export default function NodePage() { loadNodes(); }, [loadNodes]); + useEffect(() => { + if (!usingPollingFallback) { + return; + } + + void loadNodes({ silent: true }); + + const interval = window.setInterval(() => { + void loadNodes({ silent: true }); + }, NODE_FALLBACK_REFRESH_INTERVAL_MS); + + return () => { + window.clearInterval(interval); + }; + }, [loadNodes, usingPollingFallback]); + // 格式化速度 const formatSpeed = (bytesPerSecond: number): string => { if (bytesPerSecond === 0) return "0 B/s"; @@ -586,6 +723,9 @@ export default function NodePage() { setForm({ id: node.id, name: node.name, + remark: node.remark || "", + tags: node.tags || "", + expiryTime: node.expiryTime || 0, serverHost: normalizedHost, serverIpV4: normalizedV4, serverIpV6: normalizedV6, @@ -821,6 +961,9 @@ export default function NodePage() { const { serverHost, ...rest } = form; const data = { ...rest, + remark: form.remark.trim(), + tags: form.tags.trim(), + expiryTime: form.expiryTime, extraIPs: form.extraIPs, serverIp: form.serverIpV4?.trim() || @@ -842,6 +985,9 @@ export default function NodePage() { ? { ...n, name: form.name, + remark: form.remark.trim(), + tags: form.tags.trim(), + expiryTime: form.expiryTime, serverIp: form.serverIpV4?.trim() || form.serverIpV6?.trim() || @@ -878,6 +1024,9 @@ export default function NodePage() { setForm({ id: null, name: "", + remark: "", + tags: "", + expiryTime: 0, serverHost: "", serverIpV4: "", serverIpV6: "", @@ -1010,6 +1159,20 @@ export default function NodePage() { }), ); + const nodeExpiryStats = useMemo(() => { + return nodeList.reduce( + (acc, node) => { + const meta = getNodeExpiryMeta(node.expiryTime); + + if (meta.state === "expired") acc.expired += 1; + if (meta.state === "expiringSoon") acc.expiringSoon += 1; + if (node.expiryTime && node.expiryTime > 0) acc.withExpiry += 1; + return acc; + }, + { expired: 0, expiringSoon: 0, withExpiry: 0 }, + ); + }, [nodeList]); + // 根据排序顺序获取节点列表 const sortedNodes = useMemo((): Node[] => { if (!nodeList || nodeList.length === 0) return []; @@ -1022,13 +1185,40 @@ export default function NodePage() { filteredNodes = filteredNodes.filter( (n) => (n.name && n.name.toLowerCase().includes(lowerKeyword)) || + (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) || + (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) || (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) || (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) || (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)), ); } + if (nodeFilterMode !== "all") { + filteredNodes = filteredNodes.filter((node) => { + const expiryMeta = getNodeExpiryMeta(node.expiryTime); + + switch (nodeFilterMode) { + case "expiringSoon": + return expiryMeta.state === "expiringSoon"; + case "expired": + return expiryMeta.state === "expired"; + case "withExpiry": + return !!node.expiryTime && node.expiryTime > 0; + default: + return true; + } + }); + } + const sortedByDb = [...filteredNodes].sort((a, b) => { + const expiryDiff = + getNodeExpiryMeta(a.expiryTime).sortWeight - + getNodeExpiryMeta(b.expiryTime).sortWeight; + + if (expiryDiff !== 0) { + return expiryDiff; + } + const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; @@ -1060,7 +1250,7 @@ export default function NodePage() { } return sortedByDb; - }, [nodeList, nodeOrder, searchKeyword]); + }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]); const sortableNodeIds = useMemo( () => sortedNodes.map((n) => n.id), @@ -1070,15 +1260,41 @@ export default function NodePage() { return (
-
+
setIsSearchVisible(false)} onOpen={() => setIsSearchVisible(true)} /> + +
@@ -1162,7 +1378,11 @@ export default function NodePage() { className="mb-4" color="warning" description={ - wsConnecting ? "监控连接中..." : "监控连接已断开,正在重连..." + wsConnecting + ? "监控连接中..." + : usingPollingFallback + ? "监控连接已断开,已切换为列表自动刷新兜底模式。" + : "监控连接已断开,正在重连..." } variant="flat" /> @@ -1185,13 +1405,14 @@ export default function NodePage() {
{sortedNodes.map((node) => { const isRemoteNode = node.isRemote === 1; + const expiryMeta = getNodeExpiryMeta(node.expiryTime); return ( {(listeners) => (
@@ -1259,8 +1480,60 @@ export default function NodePage() { {getRemoteSyncErrorMessage(node.syncError)}
)} + {expiryMeta.isHighlighted && ( +
+ 节点到期提醒 + {expiryMeta.label} +
+ )} {/* 基础信息 */}
+ {(node.remark?.trim() || node.tags?.trim()) && ( +
+ {node.remark?.trim() && ( +
+ {node.remark.trim()} +
+ )} + {normalizeNodeTags(node.tags).length > 0 && ( +
+ {normalizeNodeTags(node.tags).map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ )} + {node.expiryTime && node.expiryTime > 0 && ( +
+ 到期时间 +
+
+ {formatNodeExpiry(node.expiryTime)} +
+
+ + {expiryMeta.label} + +
+
+
+ )}
IP @@ -1562,6 +1835,52 @@ export default function NodePage() { } /> +