From 116e00b7f0b0e6c3dfc8329e997a56bc7b6a3d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 00:11:43 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(leases):=20=E7=A7=9F=E7=BA=A6?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20UI=20=E4=BC=98=E5=8C=96=20+=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=20API=20+=20Dev=20Build=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - 后端 LeaseResponse: 新增 created_at 字段,leaseToResponse 转换 CreatedAtNs - handler_lease: leaseSortKey 扩展至 6 个排序字段(account/node_tag/egress_ip/created_at/expiry/last_accessed) - server.go: 注册 PUT /leases/{account} 绑定租约路由 - PlatformLeasesPanel.tsx: 新增完整租约面板组件(搜索/排序/分页/绑定/解绑) - api.ts: 新增 listPlatformLeases、deletePlatformLease、bindPlatformLease API - types.ts: LeaseResponse 新增 created_at 字段 - theme.css: 租约面板、绑定表单、排序列头样式 - .github/workflows/dev-build.yml: 新增 Dev Build CI workflow(手动触发、多平台构建+Docker) - .gitignore: 更新忽略规则 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .github/workflows/dev-build.yml | 190 ++++++++++++ .gitignore | 9 +- internal/api/handler_lease.go | 37 ++- internal/api/server.go | 1 + internal/service/control_plane_leases.go | 57 ++++ webui/package-lock.json | 19 +- .../features/platforms/PlatformDetailPage.tsx | 15 +- .../platforms/PlatformLeasesPanel.tsx | 293 ++++++++++++++++++ webui/src/features/platforms/api.ts | 47 ++- webui/src/features/platforms/types.ts | 11 + webui/src/styles/theme.css | 72 +++++ 11 files changed, 729 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/dev-build.yml create mode 100644 webui/src/features/platforms/PlatformLeasesPanel.tsx diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 00000000..47d3c5d3 --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,190 @@ +name: Dev Build + +on: + workflow_dispatch: + inputs: + version_suffix: + description: 'Version suffix (leave empty for auto: dev-YYYYMMDD-sha8)' + required: false + type: string + +concurrency: + group: dev-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-binaries: + name: Build Binaries + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Compute Version + id: version + run: | + SUFFIX="${{ inputs.version_suffix }}" + if [ -z "$SUFFIX" ]; then + # Auto-generate: dev-YYYYMMDD-sha8 + VERSION="dev-$(date -u +'%Y%m%d')-${GITHUB_SHA::8}" + elif [[ "$SUFFIX" == dev-* ]]; then + # Already has dev- prefix + VERSION="$SUFFIX" + else + VERSION="dev-$SUFFIX" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build WebUI + working-directory: ./webui + run: | + npm ci + npm run build + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + + - name: Build Go Binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=${{ steps.version.outputs.version }} + GIT_COMMIT=${GITHUB_SHA::8} + BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + + OUTPUT_NAME=resin-${GOOS}-${GOARCH} + if [ "$GOOS" = "windows" ]; then + OUTPUT_NAME="${OUTPUT_NAME}.exe" + fi + echo "OUTPUT_NAME=${OUTPUT_NAME}" >> $GITHUB_ENV + + mkdir -p build + + go build -trimpath -tags "with_quic with_wireguard with_grpc with_utls with_embedded_tor with_naive_outbound" \ + -ldflags="-s -w \ + -X github.com/Resinat/Resin/internal/buildinfo.Version=${VERSION} \ + -X github.com/Resinat/Resin/internal/buildinfo.GitCommit=${GIT_COMMIT} \ + -X github.com/Resinat/Resin/internal/buildinfo.BuildTime=${BUILD_TIME}" \ + -o build/${OUTPUT_NAME} ./cmd/resin + + cd build + + SIMPLE_NAME="resin" + if [ "$GOOS" = "windows" ]; then + SIMPLE_NAME="resin.exe" + fi + cp ${OUTPUT_NAME} ${SIMPLE_NAME} + + if [ "$GOOS" = "windows" ]; then + zip resin-${GOOS}-${GOARCH}.zip ${SIMPLE_NAME} + PACKAGE_NAME="resin-${GOOS}-${GOARCH}.zip" + else + tar -czvf resin-${GOOS}-${GOARCH}.tar.gz ${SIMPLE_NAME} + PACKAGE_NAME="resin-${GOOS}-${GOARCH}.tar.gz" + fi + + rm ${SIMPLE_NAME} + echo "PACKAGE_NAME=${PACKAGE_NAME}" >> $GITHUB_ENV + + - name: Upload Release Package Artifact + uses: actions/upload-artifact@v4 + with: + name: dev-release-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/${{ env.PACKAGE_NAME }} + retention-days: 7 + + - name: Upload Linux bin for Docker + if: matrix.goos == 'linux' + uses: actions/upload-artifact@v4 + with: + name: dev-binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/${{ env.OUTPUT_NAME }} + retention-days: 1 + + docker: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: build-binaries + permissions: + contents: read + packages: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Compute Version + id: version + run: | + SUFFIX="${{ inputs.version_suffix }}" + if [ -z "$SUFFIX" ]; then + VERSION="dev-$(date -u +'%Y%m%d')-${GITHUB_SHA::8}" + elif [[ "$SUFFIX" == dev-* ]]; then + VERSION="$SUFFIX" + else + VERSION="dev-$SUFFIX" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Download Linux amd64 binary + uses: actions/download-artifact@v4 + with: + name: dev-binary-linux-amd64 + path: release-bin/linux/amd64/ + + - name: Download Linux arm64 binary + uses: actions/download-artifact@v4 + with: + name: dev-binary-linux-arm64 + path: release-bin/linux/arm64/ + + - name: Give binaries execute permission + run: chmod +x release-bin/linux/amd64/resin-linux-amd64 release-bin/linux/arm64/resin-linux-arm64 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./.github/Dockerfile.release + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} + ghcr.io/${{ github.repository }}:dev-latest diff --git a/.gitignore b/.gitignore index 6bad45d3..53c5b2e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ /resin sing-box-reference -.agents \ No newline at end of file +.agents +.ace-tool/ +.claude/ +.cursor/ +.trellis/ +AGENTS.md +data/ +start.sh \ No newline at end of file diff --git a/internal/api/handler_lease.go b/internal/api/handler_lease.go index 15757a99..161e4ca2 100644 --- a/internal/api/handler_lease.go +++ b/internal/api/handler_lease.go @@ -19,6 +19,12 @@ func validateAccountPath(r *http.Request) (string, error) { func leaseSortKey(sortBy string, l service.LeaseResponse) string { switch sortBy { + case "node_tag": + return l.NodeTag + case "egress_ip": + return l.EgressIP + case "created_at": + return l.CreatedAt case "expiry": return l.Expiry case "last_accessed": @@ -92,7 +98,7 @@ func HandleListLeases(cp *service.ControlPlaneService) http.HandlerFunc { leases = filtered } - sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "expiry", "last_accessed"}, "expiry", "asc") + sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "node_tag", "egress_ip", "created_at", "expiry", "last_accessed"}, "expiry", "asc") if !ok { return } @@ -164,6 +170,35 @@ func HandleDeleteAllLeases(cp *service.ControlPlaneService) http.HandlerFunc { } } +// HandleBindLease returns a handler for PUT /api/v1/platforms/{id}/leases/{account}. +func HandleBindLease(cp *service.ControlPlaneService) http.HandlerFunc { + type bindRequest struct { + NodeHash string `json:"node_hash"` + } + return func(w http.ResponseWriter, r *http.Request) { + platformID, ok := requireUUIDPathParam(w, r, "id", "platform_id") + if !ok { + return + } + account, err := validateAccountPath(r) + if err != nil { + writeServiceError(w, err) + return + } + var req bindRequest + if err := DecodeBody(r, &req); err != nil { + writeDecodeBodyError(w, err) + return + } + lease, err := cp.BindLease(platformID, account, req.NodeHash) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, lease) + } +} + // HandleIPLoad returns a handler for GET /api/v1/platforms/{id}/ip-load. func HandleIPLoad(cp *service.ControlPlaneService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/server.go b/internal/api/server.go index fa070244..ce043b9a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -89,6 +89,7 @@ func NewServerWithAddress( authed.Handle("GET /api/v1/platforms/{id}/leases", HandleListLeases(cp)) authed.Handle("DELETE /api/v1/platforms/{id}/leases", HandleDeleteAllLeases(cp)) authed.Handle("GET /api/v1/platforms/{id}/leases/{account}", HandleGetLease(cp)) + authed.Handle("PUT /api/v1/platforms/{id}/leases/{account}", HandleBindLease(cp)) authed.Handle("DELETE /api/v1/platforms/{id}/leases/{account}", HandleDeleteLease(cp)) authed.Handle("GET /api/v1/platforms/{id}/ip-load", HandleIPLoad(cp)) diff --git a/internal/service/control_plane_leases.go b/internal/service/control_plane_leases.go index 1702e2ba..6f9f6e57 100644 --- a/internal/service/control_plane_leases.go +++ b/internal/service/control_plane_leases.go @@ -20,6 +20,7 @@ type LeaseResponse struct { NodeHash string `json:"node_hash"` NodeTag string `json:"node_tag"` EgressIP string `json:"egress_ip"` + CreatedAt string `json:"created_at"` Expiry string `json:"expiry"` LastAccessed string `json:"last_accessed"` } @@ -31,6 +32,7 @@ func leaseToResponse(lease model.Lease, nodeTag string) LeaseResponse { NodeHash: lease.NodeHash, NodeTag: nodeTag, EgressIP: lease.EgressIP, + CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), } @@ -63,6 +65,7 @@ func (s *ControlPlaneService) ListLeases(platformID string) ([]LeaseResponse, er Account: account, NodeHash: lease.NodeHash.Hex(), EgressIP: lease.EgressIP.String(), + CreatedAtNs: lease.CreatedAtNs, ExpiryNs: lease.ExpiryNs, LastAccessedNs: lease.LastAccessedNs, }, s.resolveLeaseNodeTag(lease.NodeHash))) @@ -148,6 +151,60 @@ func (s *ControlPlaneService) DeleteAllLeases(platformID string) error { return nil } +// BindLease binds (or rebinds) an account to a specific node on the given platform. +// The node must be routable on the platform. +func (s *ControlPlaneService) BindLease(platformID, account, nodeHashHex string) (*LeaseResponse, error) { + account = strings.TrimSpace(account) + if account == "" { + return nil, invalidArg("account: must be non-empty") + } + nodeHashHex = strings.TrimSpace(nodeHashHex) + h, err := node.ParseHex(nodeHashHex) + if err != nil { + return nil, invalidArg("node_hash: invalid format") + } + + plat, ok := s.Pool.GetPlatform(platformID) + if !ok { + return nil, notFound("platform not found") + } + + if !plat.View().Contains(h) { + return nil, notFound("node is not routable on this platform") + } + + entry, ok := s.Pool.GetEntry(h) + if !ok { + return nil, notFound("node not found") + } + egressIP := entry.GetEgressIP() + if !egressIP.IsValid() { + return nil, invalidArg("node has no egress IP") + } + + nowNs := time.Now().UnixNano() + ttlNs := plat.StickyTTLNs + if ttlNs <= 0 { + ttlNs = int64(24 * time.Hour) // default 24h + } + + ml := model.Lease{ + PlatformID: platformID, + Account: account, + NodeHash: h.Hex(), + EgressIP: egressIP.String(), + CreatedAtNs: nowNs, + ExpiryNs: nowNs + ttlNs, + LastAccessedNs: nowNs, + } + if err := s.Router.UpsertLease(ml); err != nil { + return nil, internal("bind lease", err) + } + + resp := leaseToResponse(ml, s.resolveLeaseNodeTag(h)) + return &resp, nil +} + // IPLoadEntry is the API response for IP load stats. type IPLoadEntry struct { EgressIP string `json:"egress_ip"` diff --git a/webui/package-lock.json b/webui/package-lock.json index 88505cff..96b10159 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -73,7 +73,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1666,7 +1665,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1677,7 +1675,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1743,7 +1740,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2008,7 +2004,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2117,7 +2112,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2491,7 +2485,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2911,7 +2904,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3307,7 +3299,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3369,7 +3360,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3379,7 +3369,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3392,7 +3381,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -3443,7 +3431,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3538,8 +3525,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3747,7 +3733,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3865,7 +3850,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3995,7 +3979,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/webui/src/features/platforms/PlatformDetailPage.tsx b/webui/src/features/platforms/PlatformDetailPage.tsx index b0c64172..829d5035 100644 --- a/webui/src/features/platforms/PlatformDetailPage.tsx +++ b/webui/src/features/platforms/PlatformDetailPage.tsx @@ -33,12 +33,14 @@ import { type PlatformFormValues, } from "./formModel"; import { PlatformMonitorPanel } from "./PlatformMonitorPanel"; +import { PlatformLeasesPanel } from "./PlatformLeasesPanel"; -type PlatformDetailTab = "monitor" | "config" | "ops"; +type PlatformDetailTab = "monitor" | "leases" | "config" | "ops"; const ZERO_UUID = "00000000-0000-0000-0000-000000000000"; const DETAIL_TABS: Array<{ key: PlatformDetailTab; label: string; hint: string }> = [ { key: "monitor", label: "监控", hint: "平台运行态趋势和快照" }, + { key: "leases", label: "租约", hint: "查看和管理当前平台的租约绑定" }, { key: "config", label: "配置", hint: "过滤规则与分配策略" }, { key: "ops", label: "运维", hint: "重置、清租约、删除操作" }, ]; @@ -315,6 +317,17 @@ export function PlatformDetailPage() { ) : null} + {activeTab === "leases" ? ( +
+ +
+ ) : null} + {activeTab === "config" ? (
(); + +type SortField = "account" | "node_tag" | "egress_ip" | "created_at" | "expiry" | "last_accessed"; +type SortOrder = "asc" | "desc"; + +type Props = { + platform: Platform; + showToast: (type: "success" | "error", message: string) => void; +}; + +export function PlatformLeasesPanel({ platform, showToast }: Props) { + const { t } = useI18n(); + const queryClient = useQueryClient(); + + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(50); + const [bindOpen, setBindOpen] = useState(false); + const [bindAccount, setBindAccount] = useState(""); + const [selectedNodeHash, setSelectedNodeHash] = useState(""); + const [sortBy, setSortBy] = useState("account"); + const [sortOrder, setSortOrder] = useState("asc"); + + const queryKey = ["platform-leases", platform.id, search, page, pageSize, sortBy, sortOrder]; + + const leasesQuery = useQuery({ + queryKey, + queryFn: () => + listPlatformLeases(platform.id, { + limit: pageSize, + offset: page * pageSize, + account: search || undefined, + fuzzy: true, + sort_by: sortBy, + sort_order: sortOrder, + }), + refetchInterval: 15_000, + placeholderData: (prev) => prev, + }); + + const leasesPage = leasesQuery.data ?? { items: [], total: 0, limit: pageSize, offset: 0 }; + const leases = leasesPage.items; + const totalPages = Math.max(1, Math.ceil(leasesPage.total / pageSize)); + + const nodesQuery = useQuery({ + queryKey: ["platform-nodes", platform.id], + queryFn: () => listNodes({ platform_id: platform.id, limit: 10000 }), + enabled: bindOpen, + }); + + const sortedNodes = (nodesQuery.data?.items ?? []).slice().sort((a, b) => { + const aLat = a.reference_latency_ms; + const bLat = b.reference_latency_ms; + if (aLat == null && bLat == null) return 0; + if (aLat == null) return 1; + if (bLat == null) return -1; + return aLat - bLat; + }); + + const invalidateLeases = async () => { + await queryClient.invalidateQueries({ queryKey: ["platform-leases", platform.id] }); + await queryClient.invalidateQueries({ queryKey: ["platform-monitor"] }); + }; + + const deleteMutation = useMutation({ + mutationFn: (account: string) => deletePlatformLease(platform.id, account), + onSuccess: async (_, account) => { + await invalidateLeases(); + showToast("success", t("租约 {{account}} 已解绑", { account })); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + + const bindMutation = useMutation({ + mutationFn: () => bindPlatformLease(platform.id, bindAccount.trim(), selectedNodeHash), + onSuccess: async (lease) => { + await invalidateLeases(); + setBindOpen(false); + setBindAccount(""); + setSelectedNodeHash(""); + showToast("success", t("租约 {{account}} 已绑定到 {{ip}}", { account: lease.account, ip: lease.egress_ip })); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + + const handleDelete = (account: string) => { + const confirmed = window.confirm(t("确认解绑租约 {{account}}?", { account })); + if (confirmed) { + deleteMutation.mutate(account); + } + }; + + const handleBind = (e: React.FormEvent) => { + e.preventDefault(); + if (!bindAccount.trim() || !selectedNodeHash) return; + bindMutation.mutate(); + }; + + const changePageSize = (size: number) => { + setPageSize(size); + setPage(0); + }; + + const toggleSort = (field: SortField) => { + if (sortBy === field) { + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortBy(field); + setSortOrder("asc"); + } + setPage(0); + }; + + const sortHeader = (label: string, field: SortField): ReactNode => { + const active = sortBy === field; + const Icon = active ? (sortOrder === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown; + return ( + toggleSort(field)}> + {label} + + + ); + }; + + const leaseColumns = [ + columnHelper.accessor("account", { + header: () => sortHeader(t("Account"), "account"), + cell: (info) => {info.getValue()}, + }), + columnHelper.accessor("node_tag", { + header: () => sortHeader(t("节点"), "node_tag"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("egress_ip", { + header: () => sortHeader(t("出口 IP"), "egress_ip"), + }), + columnHelper.accessor("created_at", { + header: () => sortHeader(t("绑定时间"), "created_at"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.accessor("expiry", { + header: () => sortHeader(t("到期时间"), "expiry"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.accessor("last_accessed", { + header: () => sortHeader(t("最近访问"), "last_accessed"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.display({ + id: "actions", + header: "", + cell: (info) => ( + + ), + }), + ]; + + return ( +
+
+
+ + { + setSearch(e.target.value); + setPage(0); + }} + /> +
+ +
+ + {bindOpen ? ( +
+
+ setBindAccount(e.target.value)} + required + /> +
+
+ +
+
+ + +
+
+ ) : null} + + {leasesQuery.isLoading ?

{t("正在加载租约数据...")}

: null} + + {leasesQuery.isError ? ( +
+ + {formatApiErrorMessage(leasesQuery.error, t)} +
+ ) : null} + + {!leasesQuery.isLoading && !leases.length ? ( +
+ +

{t("没有租约")}

+
+ ) : null} + + {leases.length ? ( + l.account} /> + ) : null} + + +
+ ); +} diff --git a/webui/src/features/platforms/api.ts b/webui/src/features/platforms/api.ts index fbd39bce..b87848f6 100644 --- a/webui/src/features/platforms/api.ts +++ b/webui/src/features/platforms/api.ts @@ -1,5 +1,5 @@ import { apiRequest } from "../../lib/api-client"; -import type { PageResponse, Platform, PlatformCreateInput, PlatformUpdateInput } from "./types"; +import type { LeaseResponse, PageResponse, Platform, PlatformCreateInput, PlatformUpdateInput } from "./types"; const basePath = "/api/v1/platforms"; @@ -111,3 +111,48 @@ export async function clearAllPlatformLeases(id: string): Promise { method: "DELETE", }); } + +export type ListLeasesInput = { + limit?: number; + offset?: number; + account?: string; + fuzzy?: boolean; + sort_by?: string; + sort_order?: string; +}; + +export async function listPlatformLeases( + platformId: string, + input: ListLeasesInput = {}, +): Promise> { + const query = new URLSearchParams({ + limit: String(input.limit ?? 50), + offset: String(input.offset ?? 0), + }); + if (input.account?.trim()) { + query.set("account", input.account.trim()); + if (input.fuzzy !== false) { + query.set("fuzzy", "true"); + } + } + if (input.sort_by) query.set("sort_by", input.sort_by); + if (input.sort_order) query.set("sort_order", input.sort_order); + return apiRequest>(`${basePath}/${platformId}/leases?${query.toString()}`); +} + +export async function deletePlatformLease(platformId: string, account: string): Promise { + await apiRequest(`${basePath}/${platformId}/leases/${encodeURIComponent(account)}`, { + method: "DELETE", + }); +} + +export async function bindPlatformLease( + platformId: string, + account: string, + nodeHash: string, +): Promise { + return apiRequest( + `${basePath}/${platformId}/leases/${encodeURIComponent(account)}`, + { method: "PUT", body: { node_hash: nodeHash } }, + ); +} diff --git a/webui/src/features/platforms/types.ts b/webui/src/features/platforms/types.ts index a47a1feb..29bff3e8 100644 --- a/webui/src/features/platforms/types.ts +++ b/webui/src/features/platforms/types.ts @@ -44,3 +44,14 @@ export type PlatformUpdateInput = { reverse_proxy_fixed_account_header?: string; allocation_policy?: PlatformAllocationPolicy; }; + +export type LeaseResponse = { + platform_id: string; + account: string; + node_hash: string; + node_tag: string; + egress_ip: string; + created_at: string; + expiry: string; + last_accessed: string; +}; diff --git a/webui/src/styles/theme.css b/webui/src/styles/theme.css index 413808b3..79ac2b61 100644 --- a/webui/src/styles/theme.css +++ b/webui/src/styles/theme.css @@ -2591,6 +2591,78 @@ a { justify-content: flex-end; } +/* ── Lease panel ── */ +.platform-leases-panel { + display: flex; + flex-direction: column; + gap: 10px; +} +.platform-leases-toolbar { + display: flex; + align-items: center; + gap: 8px; +} +.platform-leases-search { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + max-width: 320px; +} +.platform-leases-search .form-input { + flex: 1; +} +.platform-leases-bind-form { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid rgba(37, 72, 120, 0.14); + border-radius: 10px; + background: rgba(255, 255, 255, 0.84); + flex-wrap: nowrap; +} +.bind-field { + min-width: 0; +} +.bind-field-account { + flex: 1; +} +.bind-field-node { + flex: 2; +} +.bind-field .form-input, +.bind-field .form-select { + width: 100%; +} +.bind-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} +.bind-actions .btn { + white-space: nowrap; +} +.lease-account-cell { + font-family: var(--font-mono, monospace); + font-size: 12px; +} +.lease-sort-header { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + user-select: none; + color: var(--text-muted); + transition: color 0.15s; +} +.lease-sort-header:hover { + color: var(--text); +} +.lease-sort-header.active { + color: var(--primary); +} + .platform-ops-list { --platform-op-btn-width: 164px; display: flex; From aa591a0443637d400c9e2609471176b2465e79b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 00:19:34 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(ci):=20Docker=20tag=20=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=90=8D=E8=BD=AC=E5=B0=8F=E5=86=99=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20buildx=20=E6=9E=84=E5=BB=BA=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - dev-build.yml: 新增 Lowercase repository name 步骤,将 github.repository 转为小写后用于 Docker tag via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .github/workflows/dev-build.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 47d3c5d3..55e3feda 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -171,6 +171,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Lowercase repository name + run: echo "REPO_LC=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -186,5 +189,5 @@ jobs: push: true platforms: linux/amd64,linux/arm64 tags: | - ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} - ghcr.io/${{ github.repository }}:dev-latest + ghcr.io/${{ env.REPO_LC }}:${{ steps.version.outputs.version }} + ghcr.io/${{ env.REPO_LC }}:dev-latest From 1e2202e6d431520d5420a26a91b4e6edac532264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 01:14:58 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(theme):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9A=97=E9=BB=91=E4=B8=BB=E9=A2=98=20+=20=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - webui/index.html: 添加 FOUC 防闪脚本,DOM 渲染前设置 data-theme - webui/src/stores/theme-store.ts: 新建 Zustand theme store,支持 system/light/dark 三态切换 - webui/src/components/ThemeToggle.tsx: 新建主题切换按钮,复用 locale-switch-compact 样式 - webui/src/components/AppShell.tsx: 侧栏集成 ThemeToggle 组件 - webui/src/i18n/translations.ts: 新增切换主题/深色/浅色/系统 4 个翻译键 - webui/src/styles/theme.css: 追加 [data-theme="dark"] 暗色 CSS 变量覆盖及组件适配 - webui/src/components/ui/Switch.css: 追加 Switch 暗色滑块样式 --- webui/index.html | 9 + webui/src/components/AppShell.tsx | 2 + webui/src/components/ThemeToggle.tsx | 32 ++ webui/src/components/ui/Switch.css | 9 + webui/src/i18n/translations.ts | 4 + webui/src/stores/theme-store.ts | 70 +++++ webui/src/styles/theme.css | 447 +++++++++++++++++++++++++++ 7 files changed, 573 insertions(+) create mode 100644 webui/src/components/ThemeToggle.tsx create mode 100644 webui/src/stores/theme-store.ts diff --git a/webui/index.html b/webui/index.html index 939be235..5edaa66b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7,6 +7,15 @@ Resin · Sticky Proxy Pool +
diff --git a/webui/src/components/AppShell.tsx b/webui/src/components/AppShell.tsx index f4eccd5e..375a896f 100644 --- a/webui/src/components/AppShell.tsx +++ b/webui/src/components/AppShell.tsx @@ -19,6 +19,7 @@ import { useAuthStore } from "../features/auth/auth-store"; import { getEnvConfig } from "../features/systemConfig/api"; import { useI18n } from "../i18n"; import { LanguageSwitcher } from "./LanguageSwitcher"; +import { ThemeToggle } from "./ThemeToggle"; type NavItem = { label: string; @@ -133,6 +134,7 @@ export function AppShell() { ) : (
)} - -
+ +
diff --git a/webui/src/features/systemConfig/api.ts b/webui/src/features/systemConfig/api.ts index 9fca17f5..f55de414 100644 --- a/webui/src/features/systemConfig/api.ts +++ b/webui/src/features/systemConfig/api.ts @@ -1,3 +1,4 @@ +import { getStoredAuthToken } from "../auth/auth-store"; import { apiRequest } from "../../lib/api-client"; import type { EnvConfig, RuntimeConfig, RuntimeConfigPatch } from "./types"; @@ -105,3 +106,52 @@ export async function patchSystemConfig(patch: RuntimeConfigPatch): Promise { return await apiRequest(path + "/env"); } + +// --- Data export / import --- + +export type ImportResult = { + platforms_created: number; + platforms_skipped: number; + platforms_overwritten: number; + subscriptions_created: number; + subscriptions_skipped: number; + subscriptions_overwritten: number; + errors: string[]; +}; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ""; + +export async function exportData(): Promise { + const token = getStoredAuthToken(); + const headers: HeadersInit = {}; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const response = await fetch(`${API_BASE_URL}/api/v1/data/export`, { headers }); + if (!response.ok) { + throw new Error(`Export failed: ${response.statusText}`); + } + const blob = await response.blob(); + const disposition = response.headers.get("Content-Disposition") ?? ""; + const match = disposition.match(/filename="?([^"]+)"?/); + const filename = match?.[1] ?? "resin-export.json"; + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export async function importData( + payload: unknown, + strategy: "skip" | "overwrite", +): Promise { + return apiRequest(`/api/v1/data/import?strategy=${strategy}`, { + method: "POST", + body: payload as Record, + }); +} diff --git a/webui/src/i18n/translations.ts b/webui/src/i18n/translations.ts index 8d40cc83..b8812326 100644 --- a/webui/src/i18n/translations.ts +++ b/webui/src/i18n/translations.ts @@ -625,6 +625,23 @@ const EXACT_ZH_TO_EN: Record = { "总请求": "Total requests", "最近错误:{{message}}": "Recent error: {{message}}", "配置已更新({{count}} 项变更)": "Config updated ({{count}} changes)", + "数据管理": "Data Management", + "导出平台与订阅配置为 JSON 文件,用于备份或迁移。": "Export platform and subscription configs as JSON for backup or migration.", + "导出 JSON": "Export JSON", + "导出中...": "Exporting...", + "导出成功": "Export successful", + "导入 JSON 文件以恢复平台与订阅配置。": "Import a JSON file to restore platform and subscription configs.", + "选择 JSON 文件": "Select JSON file", + "冲突策略": "Conflict strategy", + "跳过已存在": "Skip existing", + "覆盖已存在": "Overwrite existing", + "导入": "Import", + "导入中...": "Importing...", + "请先选择 JSON 文件": "Please select a JSON file first", + "JSON 文件解析失败": "Failed to parse JSON file", + "创建 {{count}} 项": "{{count}} created", + "跳过 {{count}} 项": "{{count}} skipped", + "覆盖 {{count}} 项": "{{count}} overwritten", }; export function translateDocumentTitle(locale: AppLocale): string { From 22c35aba371fcf7d88433baa151c7be0fa730346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 10:43:37 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(data):=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=A6=86=E7=9B=96=20remote=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=97=B6=20content=20=E5=AD=97=E6=AE=B5=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - internal/service/control_plane_data.go: buildSubscriptionPatch 按 source_type 条件包含 url/content 字段 - webui/src/features/systemConfig/SystemConfigPage.tsx: 移除多余的闭合 div 标签 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- internal/service/control_plane_data.go | 8 ++++++-- webui/src/features/systemConfig/SystemConfigPage.tsx | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/service/control_plane_data.go b/internal/service/control_plane_data.go index b4361b8a..9c033111 100644 --- a/internal/service/control_plane_data.go +++ b/internal/service/control_plane_data.go @@ -330,13 +330,17 @@ func buildCreateSubscriptionRequest(e ExportSubscriptionEntry) CreateSubscriptio func buildSubscriptionPatch(e ExportSubscriptionEntry) map[string]any { patch := map[string]any{ "name": strings.TrimSpace(e.Name), - "url": strings.TrimSpace(e.URL), - "content": e.Content, "update_interval": e.UpdateInterval, "enabled": e.Enabled, "ephemeral": e.Ephemeral, "ephemeral_node_evict_delay": e.EphemeralNodeEvictDelay, } + if e.SourceType == "remote" { + patch["url"] = strings.TrimSpace(e.URL) + } + if e.SourceType == "local" { + patch["content"] = e.Content + } return patch } diff --git a/webui/src/features/systemConfig/SystemConfigPage.tsx b/webui/src/features/systemConfig/SystemConfigPage.tsx index 8f3febb4..323d01fc 100644 --- a/webui/src/features/systemConfig/SystemConfigPage.tsx +++ b/webui/src/features/systemConfig/SystemConfigPage.tsx @@ -1172,7 +1172,6 @@ export function SystemConfigPage() {
-
)} From 354eab12361cacc7d1b5bba4acb35f4a4e3fe45a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 12:01:27 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(webui):=20=E4=BF=AE=E5=A4=8D=20import?= =?UTF-8?q?Data=20=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - webui/src/lib/api-client.ts: 导出 JsonValue 类型 - webui/src/features/systemConfig/api.ts: importData body 使用 JsonValue 替代 Record via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- webui/src/features/systemConfig/api.ts | 4 ++-- webui/src/lib/api-client.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webui/src/features/systemConfig/api.ts b/webui/src/features/systemConfig/api.ts index f55de414..43d41ec4 100644 --- a/webui/src/features/systemConfig/api.ts +++ b/webui/src/features/systemConfig/api.ts @@ -1,5 +1,5 @@ import { getStoredAuthToken } from "../auth/auth-store"; -import { apiRequest } from "../../lib/api-client"; +import { apiRequest, type JsonValue } from "../../lib/api-client"; import type { EnvConfig, RuntimeConfig, RuntimeConfigPatch } from "./types"; const path = "/api/v1/system/config"; @@ -152,6 +152,6 @@ export async function importData( ): Promise { return apiRequest(`/api/v1/data/import?strategy=${strategy}`, { method: "POST", - body: payload as Record, + body: payload as JsonValue, }); } diff --git a/webui/src/lib/api-client.ts b/webui/src/lib/api-client.ts index 085a0c50..4a1de2fe 100644 --- a/webui/src/lib/api-client.ts +++ b/webui/src/lib/api-client.ts @@ -3,7 +3,7 @@ import { getStoredAuthToken } from "../features/auth/auth-store"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() ?? ""; type Primitive = string | number | boolean | null; -type JsonValue = Primitive | JsonValue[] | { [key: string]: JsonValue }; +export type JsonValue = Primitive | JsonValue[] | { [key: string]: JsonValue }; export type ApiErrorBody = { error?: { From 9748d25549954bbe3a44444b16351abddf353fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Wed, 11 Mar 2026 17:38:33 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(data):=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=A6=86=E7=9B=96=E5=B9=B3=E5=8F=B0=E6=97=B6=20region?= =?UTF-8?q?=5Ffilters/regex=5Ffilters=20null=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - internal/service/control_plane_data.go: buildPlatformPatch 中将 nil slice 转为空 slice,避免 JSON 序列化为 null 被 validateFields 拒绝 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- internal/service/control_plane_data.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/service/control_plane_data.go b/internal/service/control_plane_data.go index 9c033111..dc295299 100644 --- a/internal/service/control_plane_data.go +++ b/internal/service/control_plane_data.go @@ -296,12 +296,20 @@ func buildCreatePlatformRequest(e ExportPlatformEntry) CreatePlatformRequest { } func buildPlatformPatch(e ExportPlatformEntry) map[string]any { + regexFilters := e.RegexFilters + if regexFilters == nil { + regexFilters = []string{} + } + regionFilters := e.RegionFilters + if regionFilters == nil { + regionFilters = []string{} + } patch := map[string]any{ - "sticky_ttl": e.StickyTTL, - "regex_filters": e.RegexFilters, - "region_filters": e.RegionFilters, - "allocation_policy": e.AllocationPolicy, - "reverse_proxy_miss_action": e.ReverseProxyMissAction, + "sticky_ttl": e.StickyTTL, + "regex_filters": regexFilters, + "region_filters": regionFilters, + "allocation_policy": e.AllocationPolicy, + "reverse_proxy_miss_action": e.ReverseProxyMissAction, "reverse_proxy_empty_account_behavior": e.ReverseProxyEmptyAccountBehavior, "reverse_proxy_fixed_account_header": e.ReverseProxyFixedAccountHeader, } From 3f74d7638cbdbb059a69db328bd333547e8c3421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cambiel=E2=80=9D?= <“ambiel9687@163.com”> Date: Tue, 12 May 2026 15:03:00 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=B1=A0=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=A7=9F=E7=BA=A6=E6=95=B0=E5=B1=95=E7=A4=BA=E3=80=81?= =?UTF-8?q?=E7=A7=9F=E7=BA=A6=E5=BC=B9=E7=AA=97=E8=AF=A6=E6=83=85=E4=B8=8E?= =?UTF-8?q?=E8=A7=A3=E7=BB=91=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动列表: - 节点租约数列: 后端聚合 lease_count 字段(router 新增 SnapshotNodeLoad/SnapshotNodeLoadAll),前端新增可排序列,点击打开租约弹窗 - 租约详情弹窗: 新增 NodeLeasesModal 组件,展示平台/账号/绑定时间/到期/最近访问,支持解绑操作 - 弹窗节点元信息: 租约表上方展示节点来源、出口 IP、参考延迟三个 chip 指标 - 共享格式函数: 提取 nodeFormat.ts,NodesPage 与弹窗复用 firstTag/regionToFlag/formatLatency 等工具函数 - 节点租约 API: 新增 GET /api/v1/nodes/{hash}/leases 端点,service 层新增 ListLeasesByNode - 弹窗样式优化: 修复表格横向溢出,新增 modal 卡片/table 覆写/chip 行等样式 --- internal/api/handler_node.go | 27 ++- internal/api/server.go | 1 + internal/routing/router.go | 46 ++++ internal/service/control_plane_leases.go | 99 +++++++++ internal/service/control_plane_nodes.go | 19 ++ internal/service/control_plane_platform.go | 1 + webui/src/features/nodes/NodeLeasesModal.tsx | 213 +++++++++++++++++++ webui/src/features/nodes/NodesPage.tsx | 132 +++++------- webui/src/features/nodes/api.ts | 17 +- webui/src/features/nodes/nodeFormat.ts | 80 +++++++ webui/src/features/nodes/types.ts | 14 +- webui/src/styles/theme.css | 98 +++++++++ 12 files changed, 667 insertions(+), 80 deletions(-) create mode 100644 webui/src/features/nodes/NodeLeasesModal.tsx create mode 100644 webui/src/features/nodes/nodeFormat.ts diff --git a/internal/api/handler_node.go b/internal/api/handler_node.go index 53809b3c..bc02491b 100644 --- a/internal/api/handler_node.go +++ b/internal/api/handler_node.go @@ -42,6 +42,8 @@ func compareNodeSummaries(sortBy string, a, b service.NodeSummary) int { order = cmp.Compare(a.FailureCount, b.FailureCount) case "region": order = strings.Compare(a.Region, b.Region) + case "lease_count": + order = cmp.Compare(a.LeaseCount, b.LeaseCount) default: order = strings.Compare(nodeTagSortKey(a), nodeTagSortKey(b)) } @@ -152,7 +154,7 @@ func HandleListNodes(cp *service.ControlPlaneService) http.HandlerFunc { return } - sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"tag", "created_at", "failure_count", "region"}, "tag", "asc") + sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"tag", "created_at", "failure_count", "region", "lease_count"}, "tag", "asc") if !ok { return } @@ -211,3 +213,26 @@ func HandleProbeLatency(cp *service.ControlPlaneService) http.HandlerFunc { WriteJSON(w, http.StatusOK, result) } } + +// HandleListNodeLeases returns a handler for GET /api/v1/nodes/{hash}/leases. +// It returns every lease currently bound to the node; pass platform_id=... to +// scope the result to a single platform. +func HandleListNodeLeases(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + hash := PathParam(r, "hash") + platformID, ok := parseOptionalUUIDQuery(w, r, "platform_id", "platform_id") + if !ok { + return + } + pid := "" + if platformID != nil { + pid = *platformID + } + leases, err := cp.ListLeasesByNode(hash, pid) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, leases) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 54fab0ab..c740f1e0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -112,6 +112,7 @@ func NewServerWithAddress( // Nodes. authed.Handle("GET /api/v1/nodes", HandleListNodes(cp)) authed.Handle("GET /api/v1/nodes/{hash}", HandleGetNode(cp)) + authed.Handle("GET /api/v1/nodes/{hash}/leases", HandleListNodeLeases(cp)) authed.Handle("POST /api/v1/nodes/{hash}/actions/probe-egress", HandleProbeEgress(cp)) authed.Handle("POST /api/v1/nodes/{hash}/actions/probe-latency", HandleProbeLatency(cp)) diff --git a/internal/routing/router.go b/internal/routing/router.go index 5f001886..7981d10d 100644 --- a/internal/routing/router.go +++ b/internal/routing/router.go @@ -606,6 +606,52 @@ func (r *Router) RangeLeases(platformID string, fn func(account string, lease Le return true } +// RangeAllLeases iterates over all leases across every platform. +// fn receives the owning platform ID alongside the account/lease pair. +// Returning false from fn stops the iteration early. +func (r *Router) RangeAllLeases(fn func(platformID, account string, lease Lease) bool) { + r.states.Range(func(platformID string, state *PlatformRoutingState) bool { + keepGoing := true + state.Leases.Range(func(account string, lease Lease) bool { + if !fn(platformID, account, lease) { + keepGoing = false + return false + } + return true + }) + return keepGoing + }) +} + +// SnapshotNodeLoad returns a best-effort point-in-time count of leases per +// node hash for a single platform. Empty map if the platform has no state. +func (r *Router) SnapshotNodeLoad(platformID string) map[node.Hash]int64 { + state, ok := r.states.Load(platformID) + if !ok { + return map[node.Hash]int64{} + } + out := make(map[node.Hash]int64) + state.Leases.Range(func(_ string, lease Lease) bool { + out[lease.NodeHash]++ + return true + }) + return out +} + +// SnapshotNodeLoadAll returns a best-effort point-in-time count of leases per +// node hash, aggregated across every platform. +func (r *Router) SnapshotNodeLoadAll() map[node.Hash]int64 { + out := make(map[node.Hash]int64) + r.states.Range(func(_ string, state *PlatformRoutingState) bool { + state.Leases.Range(func(_ string, lease Lease) bool { + out[lease.NodeHash]++ + return true + }) + return true + }) + return out +} + // DeleteLease removes a single lease by platform and account. // Returns true if a lease was deleted. Emits a LeaseRemove event. func (r *Router) DeleteLease(platformID, account string) bool { diff --git a/internal/service/control_plane_leases.go b/internal/service/control_plane_leases.go index 6f9f6e57..a93a4510 100644 --- a/internal/service/control_plane_leases.go +++ b/internal/service/control_plane_leases.go @@ -1,6 +1,7 @@ package service import ( + "sort" "strings" "time" @@ -211,6 +212,104 @@ type IPLoadEntry struct { LeaseCount int64 `json:"lease_count"` } +// NodeLeaseResponse is the API response for a lease scoped to a specific node. +// Unlike LeaseResponse, it carries the owning platform so the caller can render +// cross-platform lease bindings for a single node. +type NodeLeaseResponse struct { + PlatformID string `json:"platform_id"` + PlatformName string `json:"platform_name"` + Account string `json:"account"` + NodeHash string `json:"node_hash"` + EgressIP string `json:"egress_ip"` + CreatedAt string `json:"created_at"` + Expiry string `json:"expiry"` + LastAccessed string `json:"last_accessed"` +} + +// ListLeasesByNode returns every lease bound to the given node hash. +// When platformID is non-empty, only leases under that platform are returned; +// otherwise leases across all platforms are aggregated. +// Results are sorted by CreatedAtNs descending (newest first). +func (s *ControlPlaneService) ListLeasesByNode(nodeHashHex, platformID string) ([]NodeLeaseResponse, error) { + nodeHashHex = strings.TrimSpace(nodeHashHex) + h, err := node.ParseHex(nodeHashHex) + if err != nil { + return nil, invalidArg("node_hash: invalid format") + } + if _, ok := s.Pool.GetEntry(h); !ok { + return nil, notFound("node not found") + } + + type entry struct { + resp NodeLeaseResponse + createdAtNs int64 + } + platformNameCache := make(map[string]string) + resolvePlatformName := func(pid string) string { + if name, ok := platformNameCache[pid]; ok { + return name + } + name := "" + if plat, ok := s.Pool.GetPlatform(pid); ok { + name = plat.Name + } + platformNameCache[pid] = name + return name + } + + var entries []entry + addLease := func(pid, account string, lease routing.Lease) { + if lease.NodeHash != h { + return + } + entries = append(entries, entry{ + resp: NodeLeaseResponse{ + PlatformID: pid, + PlatformName: resolvePlatformName(pid), + Account: account, + NodeHash: lease.NodeHash.Hex(), + EgressIP: lease.EgressIP.String(), + CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), + Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), + LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), + }, + createdAtNs: lease.CreatedAtNs, + }) + } + + platformID = strings.TrimSpace(platformID) + if platformID != "" { + if _, ok := s.Pool.GetPlatform(platformID); !ok { + return nil, notFound("platform not found") + } + s.Router.RangeLeases(platformID, func(account string, lease routing.Lease) bool { + addLease(platformID, account, lease) + return true + }) + } else { + s.Router.RangeAllLeases(func(pid, account string, lease routing.Lease) bool { + addLease(pid, account, lease) + return true + }) + } + + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].createdAtNs != entries[j].createdAtNs { + return entries[i].createdAtNs > entries[j].createdAtNs + } + if entries[i].resp.PlatformName != entries[j].resp.PlatformName { + return entries[i].resp.PlatformName < entries[j].resp.PlatformName + } + return entries[i].resp.Account < entries[j].resp.Account + }) + + result := make([]NodeLeaseResponse, 0, len(entries)) + for _, e := range entries { + result = append(result, e.resp) + } + return result, nil +} + // GetIPLoad returns IP load stats for a platform. func (s *ControlPlaneService) GetIPLoad(platformID string) ([]IPLoadEntry, error) { if _, ok := s.Pool.GetPlatform(platformID); !ok { diff --git a/internal/service/control_plane_nodes.go b/internal/service/control_plane_nodes.go index 56d7f91b..40ed48ca 100644 --- a/internal/service/control_plane_nodes.go +++ b/internal/service/control_plane_nodes.go @@ -115,6 +115,25 @@ func (s *ControlPlaneService) ListNodes(filters NodeFilters) ([]NodeSummary, err if result == nil { result = []NodeSummary{} } + + if s.Router != nil { + var leaseLoads map[node.Hash]int64 + if filters.PlatformID != nil { + leaseLoads = s.Router.SnapshotNodeLoad(*filters.PlatformID) + } else { + leaseLoads = s.Router.SnapshotNodeLoadAll() + } + if len(leaseLoads) > 0 { + for i := range result { + h, err := node.ParseHex(result[i].NodeHash) + if err != nil { + continue + } + result[i].LeaseCount = leaseLoads[h] + } + } + } + return result, nil } diff --git a/internal/service/control_plane_platform.go b/internal/service/control_plane_platform.go index c0f8701b..79b402a8 100644 --- a/internal/service/control_plane_platform.go +++ b/internal/service/control_plane_platform.go @@ -590,6 +590,7 @@ type NodeSummary struct { LastAuthorityLatencyProbeAttempt string `json:"last_authority_latency_probe_attempt,omitempty"` ReferenceLatencyMs *float64 `json:"reference_latency_ms,omitempty"` LastEgressUpdateAttempt string `json:"last_egress_update_attempt,omitempty"` + LeaseCount int64 `json:"lease_count"` Tags []NodeTag `json:"tags"` } diff --git a/webui/src/features/nodes/NodeLeasesModal.tsx b/webui/src/features/nodes/NodeLeasesModal.tsx new file mode 100644 index 00000000..faa4767f --- /dev/null +++ b/webui/src/features/nodes/NodeLeasesModal.tsx @@ -0,0 +1,213 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createColumnHelper } from "@tanstack/react-table"; +import { AlertTriangle, Globe, Link2Off, Sparkles, Tag, X, Zap } from "lucide-react"; +import { useEffect } from "react"; +import { Button } from "../../components/ui/Button"; +import { Card } from "../../components/ui/Card"; +import { DataTable } from "../../components/ui/DataTable"; +import { useI18n } from "../../i18n"; +import { formatApiErrorMessage } from "../../lib/error-message"; +import { formatRelativeTime } from "../../lib/time"; +import { deletePlatformLease } from "../platforms/api"; +import { listNodeLeases } from "./api"; +import { + displayableReferenceLatencyMs, + firstTag, + formatLatency, + referenceLatencyColor, + regionToFlag, +} from "./nodeFormat"; +import type { NodeLease, NodeSummary } from "./types"; + +const columnHelper = createColumnHelper(); + +type Props = { + node: NodeSummary; + platformId?: string; + onClose: () => void; + showToast: (type: "success" | "error", message: string) => void; +}; + +export function NodeLeasesModal({ node, platformId, onClose, showToast }: Props) { + const { t } = useI18n(); + const queryClient = useQueryClient(); + + const queryKey = ["node-leases", node.node_hash, platformId ?? ""]; + + const leasesQuery = useQuery({ + queryKey, + queryFn: () => listNodeLeases(node.node_hash, platformId), + refetchInterval: 15_000, + placeholderData: (prev) => prev, + }); + + const leases = leasesQuery.data ?? []; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const invalidateRelatedQueries = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["node-leases", node.node_hash] }), + queryClient.invalidateQueries({ queryKey: ["nodes"] }), + queryClient.invalidateQueries({ queryKey: ["platform-leases"] }), + queryClient.invalidateQueries({ queryKey: ["platform-monitor"] }), + ]); + }; + + const unbindMutation = useMutation({ + mutationFn: (target: { platformId: string; account: string }) => + deletePlatformLease(target.platformId, target.account), + onSuccess: async (_, target) => { + await invalidateRelatedQueries(); + showToast("success", t("租约 {{account}} 已解绑", { account: target.account })); + }, + onError: (error) => { + showToast("error", formatApiErrorMessage(error, t)); + }, + }); + + const handleUnbind = (lease: NodeLease) => { + const confirmed = window.confirm( + t("确认解绑租约 {{account}}(平台 {{platform}})?", { + account: lease.account, + platform: lease.platform_name || lease.platform_id, + }), + ); + if (!confirmed) { + return; + } + unbindMutation.mutate({ platformId: lease.platform_id, account: lease.account }); + }; + + const columns = [ + columnHelper.accessor("platform_name", { + header: () => t("平台"), + cell: (info) => info.getValue() || info.row.original.platform_id || "-", + }), + columnHelper.accessor("account", { + header: () => t("账号"), + cell: (info) => {info.getValue()}, + }), + columnHelper.accessor("created_at", { + header: () => t("绑定时间"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.accessor("expiry", { + header: () => t("到期时间"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.accessor("last_accessed", { + header: () => t("最近访问"), + cell: (info) => formatRelativeTime(info.getValue()), + }), + columnHelper.display({ + id: "actions", + header: () => t("操作"), + cell: (info) => ( + + ), + }), + ]; + + const titleTag = firstTag(node); + const scopeHint = platformId + ? t("仅展示当前筛选平台下的租约") + : t("展示该节点在所有平台上的租约"); + const sourceLabel = titleTag; + const egressLabel = node.egress_ip + ? node.region + ? `${node.egress_ip} ${regionToFlag(node.region)}` + : node.egress_ip + : "-"; + const latencyMs = displayableReferenceLatencyMs(node); + + return ( +
+ event.stopPropagation()}> +
+
+

{t("节点 {{name}} 的租约", { name: titleTag })}

+

{scopeHint}

+
+ +
+ +
+ + + {sourceLabel} + + + + {egressLabel} + + + + + {latencyMs !== null ? formatLatency(latencyMs) : "-"} + + +
+ + {leasesQuery.data && leases.length ? ( +

{t("共 {{count}} 条", { count: leases.length })}

+ ) : null} + + {leasesQuery.isLoading ?

{t("正在加载租约数据...")}

: null} + + {leasesQuery.isError ? ( +
+ + {formatApiErrorMessage(leasesQuery.error, t)} +
+ ) : null} + + {!leasesQuery.isLoading && !leases.length ? ( +
+ +

{t("没有租约")}

+
+ ) : null} + + {leases.length ? ( + `${l.platform_id}:${l.account}`} + wrapClassName="node-leases-table-wrap" + /> + ) : null} +
+
+ ); +} diff --git a/webui/src/features/nodes/NodesPage.tsx b/webui/src/features/nodes/NodesPage.tsx index 86059996..780b4f4f 100644 --- a/webui/src/features/nodes/NodesPage.tsx +++ b/webui/src/features/nodes/NodesPage.tsx @@ -19,12 +19,20 @@ import { listPlatforms } from "../platforms/api"; import type { Platform } from "../platforms/types"; import { listSubscriptions } from "../subscriptions/api"; import { getNode, listNodes, probeEgress, probeLatency } from "./api"; +import { NodeLeasesModal } from "./NodeLeasesModal"; import type { NodeSummary } from "./types"; -import { getAllRegions, getRegionName } from "./regions"; +import { getAllRegions } from "./regions"; +import { + displayableReferenceLatencyMs, + firstTag, + formatLatency, + getNodeDisplayStatus, + referenceLatencyColor, + regionToFlag, +} from "./nodeFormat"; import type { NodeListFilters, NodeSortBy, SortOrder } from "./types"; type NodeStatusFilter = "all" | "healthy" | "circuit_open" | "error" | "disabled"; -type NodeDisplayStatus = "healthy" | "circuit_open" | "pending_test" | "error" | "disabled"; type ProbeAction = "egress" | "latency"; type NodeFilterDraft = { @@ -178,71 +186,6 @@ function draftToActiveFilters(draft: NodeFilterDraft): NodeListFilters { }; } -function firstTag(node: { display_tag?: string; tags: { tag: string }[] }): string { - if (node.display_tag && node.display_tag.trim()) { - return node.display_tag; - } - if (!node.tags.length) { - return "-"; - } - return node.tags[0].tag; -} - -function hasReferenceLatency(node: NodeSummary): node is NodeSummary & { reference_latency_ms: number } { - return typeof node.reference_latency_ms === "number"; -} - -function isPendingTestNode(node: NodeSummary): boolean { - return Boolean(node.circuit_open_since) && node.failure_count === 0; -} - -function getNodeDisplayStatus(node: NodeSummary): NodeDisplayStatus { - if (!node.enabled) { - return "disabled"; - } - if (!node.has_outbound) { - return "error"; - } - if (isPendingTestNode(node)) { - return "pending_test"; - } - if (node.circuit_open_since) { - return "circuit_open"; - } - return "healthy"; -} - -function referenceLatencyColor(latencyMs: number): string { - if (!Number.isFinite(latencyMs)) { - return "var(--text-secondary)"; - } - if (latencyMs <= 400) { - return "var(--success)"; - } - if (latencyMs <= 1000) { - return "var(--warning)"; - } - return "var(--danger)"; -} - -function displayableReferenceLatencyMs(node: NodeSummary): number | null { - if (getNodeDisplayStatus(node) !== "healthy") { - return null; - } - if (!hasReferenceLatency(node)) { - return null; - } - return node.reference_latency_ms; -} - - -function formatLatency(value: number): string { - if (!Number.isFinite(value)) { - return "-"; - } - return `${value.toFixed(0)} ms`; -} - function sortIndicator(active: boolean, order: SortOrder): string { if (!active) { return "↕"; @@ -250,16 +193,6 @@ function sortIndicator(active: boolean, order: SortOrder): string { return order === "asc" ? "▲" : "▼"; } -function regionToFlag(region: string | undefined): string { - if (!region || region.length !== 2) { - return region || "-"; - } - const code = region.toUpperCase(); - const flag = String.fromCodePoint(...[...code].map((c) => c.charCodeAt(0) + 127397)); - const name = getRegionName(code); - return name ? `${flag} ${code} (${name})` : `${flag} ${code}`; -} - export function NodesPage() { const { locale, t } = useI18n(); const location = useLocation(); @@ -273,6 +206,7 @@ export function NodesPage() { const [pageSize, setPageSize] = useState(200); const [selectedNodeHash, setSelectedNodeHash] = useState(""); const [drawerOpen, setDrawerOpen] = useState(false); + const [leasesModalNodeHash, setLeasesModalNodeHash] = useState(null); const [pendingEgressHashes, setPendingEgressHashes] = useState>(() => new Set()); const [pendingLatencyHashes, setPendingLatencyHashes] = useState>(() => new Set()); const { toasts, showToast, dismissToast } = useToast(); @@ -608,6 +542,32 @@ export function NodesPage() { return {t("健康")}; }, }), + col.accessor("lease_count", { + header: () => ( + + ), + cell: (info) => { + const node = info.row.original; + const count = node.lease_count ?? 0; + return ( + + ); + }, + }), col.accessor("created_at", { header: () => ( + {node.manually_disabled ? ( + + ) : ( + + )} ); }, @@ -746,6 +838,7 @@ export function NodesPage() { + @@ -849,7 +942,9 @@ export function NodesPage() { const status = getNodeDisplayStatus(detailNode); return (
- {status === "error" ? ( + {status === "manually_disabled" ? ( + {t("手动禁用")} + ) : status === "error" ? ( {t("错误")} ) : status === "disabled" ? ( {t("禁用")} diff --git a/webui/src/features/nodes/api.ts b/webui/src/features/nodes/api.ts index 7a89b360..a02b05d3 100644 --- a/webui/src/features/nodes/api.ts +++ b/webui/src/features/nodes/api.ts @@ -13,6 +13,7 @@ const basePath = "/api/v1/nodes"; type ApiNodeSummary = Omit & { tags?: NodeSummary["tags"] | null; enabled?: boolean | null; + manually_disabled?: boolean | null; display_tag?: string | null; last_error?: string | null; circuit_open_since?: string | null; @@ -31,6 +32,7 @@ function normalizeNode(raw: ApiNodeSummary): NodeSummary { const normalized: NodeSummary = { ...rest, enabled: raw.enabled !== false, + manually_disabled: Boolean(raw.manually_disabled), display_tag: raw.display_tag || "", tags: Array.isArray(raw.tags) ? raw.tags : [], last_error: raw.last_error || "", @@ -87,6 +89,9 @@ export async function listNodes(filters: NodeListQuery): Promise>(`${basePath}?${query.toString()}`); return { @@ -123,3 +128,19 @@ export async function listNodeLeases(hash: string, platformId?: string): Promise const data = await apiRequest(path); return Array.isArray(data) ? data : []; } + +export type DisableNodeResult = { + released_lease_count: number; +}; + +export async function disableNode(hash: string): Promise { + return apiRequest(`${basePath}/${hash}/actions/disable`, { + method: "POST", + }); +} + +export async function enableNode(hash: string): Promise { + return apiRequest(`${basePath}/${hash}/actions/enable`, { + method: "POST", + }); +} diff --git a/webui/src/features/nodes/nodeFormat.ts b/webui/src/features/nodes/nodeFormat.ts index 2b8138a2..3c8bd8d2 100644 --- a/webui/src/features/nodes/nodeFormat.ts +++ b/webui/src/features/nodes/nodeFormat.ts @@ -1,7 +1,13 @@ import type { NodeSummary } from "./types"; import { getRegionName } from "./regions"; -export type NodeDisplayStatus = "healthy" | "circuit_open" | "pending_test" | "error" | "disabled"; +export type NodeDisplayStatus = + | "healthy" + | "circuit_open" + | "pending_test" + | "error" + | "disabled" + | "manually_disabled"; export function firstTag(node: { display_tag?: string; tags: { tag: string }[] }): string { if (node.display_tag && node.display_tag.trim()) { @@ -24,6 +30,9 @@ export function isPendingTestNode(node: NodeSummary): boolean { } export function getNodeDisplayStatus(node: NodeSummary): NodeDisplayStatus { + if (node.manually_disabled) { + return "manually_disabled"; + } if (!node.enabled) { return "disabled"; } diff --git a/webui/src/features/nodes/types.ts b/webui/src/features/nodes/types.ts index e7ea194f..c107c5f2 100644 --- a/webui/src/features/nodes/types.ts +++ b/webui/src/features/nodes/types.ts @@ -8,6 +8,7 @@ export type NodeSummary = { node_hash: string; created_at: string; enabled: boolean; + manually_disabled?: boolean; display_tag?: string; has_outbound: boolean; last_error?: string; @@ -44,6 +45,7 @@ export type NodeListFilters = { egress_ip?: string; probed_since?: string; enabled?: boolean; + manually_disabled?: boolean; circuit_open?: boolean; has_outbound?: boolean; };