diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml
new file mode 100644
index 0000000..55e3fed
--- /dev/null
+++ b/.github/workflows/dev-build.yml
@@ -0,0 +1,193 @@
+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: Lowercase repository name
+ run: echo "REPO_LC=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
+ - 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/${{ env.REPO_LC }}:${{ steps.version.outputs.version }}
+ ghcr.io/${{ env.REPO_LC }}:dev-latest
diff --git a/.gitignore b/.gitignore
index 62dc1ff..8d5fe5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,12 @@
/resin
sing-box-reference
.agents
+.ace-tool/
+.claude/
+.cursor/
+.trellis/
+AGENTS.md
+data/
+start.sh
.devcontainer
-start-instance.sh
\ No newline at end of file
+start-instance.sh
diff --git a/internal/api/handler_data.go b/internal/api/handler_data.go
new file mode 100644
index 0000000..4de0f2a
--- /dev/null
+++ b/internal/api/handler_data.go
@@ -0,0 +1,42 @@
+package api
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/Resinat/Resin/internal/service"
+)
+
+// HandleExportData returns a handler for GET /api/v1/data/export.
+func HandleExportData(cp *service.ControlPlaneService) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ payload, err := cp.ExportData()
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+
+ filename := "resin-export-" + time.Now().UTC().Format("20060102-150405") + ".json"
+ w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
+ WriteJSON(w, http.StatusOK, payload)
+ }
+}
+
+// HandleImportData returns a handler for POST /api/v1/data/import.
+func HandleImportData(cp *service.ControlPlaneService) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var payload service.ExportPayload
+ if err := DecodeBody(r, &payload); err != nil {
+ writeDecodeBodyError(w, err)
+ return
+ }
+
+ strategy := r.URL.Query().Get("strategy")
+ result, err := cp.ImportData(payload, strategy)
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ WriteJSON(w, http.StatusOK, result)
+ }
+}
diff --git a/internal/api/handler_lease.go b/internal/api/handler_lease.go
index 15757a9..161e4ca 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 fa07024..54fab0a 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))
@@ -119,6 +120,10 @@ func NewServerWithAddress(
authed.Handle("GET /api/v1/geoip/lookup", HandleGeoIPLookup(cp))
authed.Handle("POST /api/v1/geoip/lookup", HandleGeoIPLookupPost(cp))
authed.Handle("POST /api/v1/geoip/actions/update-now", HandleGeoIPUpdate(cp))
+
+ // Data export / import.
+ authed.Handle("GET /api/v1/data/export", HandleExportData(cp))
+ authed.Handle("POST /api/v1/data/import", HandleImportData(cp))
}
// Request log endpoints (always registered if repo is available).
diff --git a/internal/routing/router.go b/internal/routing/router.go
index 76cb70f..5f00188 100644
--- a/internal/routing/router.go
+++ b/internal/routing/router.go
@@ -261,6 +261,16 @@ func (r *Router) tryLeaseHit(
newLease := current
newLease.LastAccessedNs = nowNs
+
+ // Auto-renew: extend lease when within 1 minute of expiry.
+ if newLease.ExpiryNs-nowNs < int64(time.Minute) {
+ ttl := plat.StickyTTLNs
+ if ttl <= 0 {
+ ttl = int64(24 * time.Hour)
+ }
+ newLease.ExpiryNs = nowNs + ttl
+ }
+
r.emitLeaseEvent(LeaseEvent{
Type: LeaseTouch,
PlatformID: plat.ID,
@@ -297,6 +307,16 @@ func (r *Router) tryLeaseSameIPRotation(
newLease := current
newLease.NodeHash = bestHash
newLease.LastAccessedNs = nowNs
+
+ // Auto-renew: extend lease when within 1 minute of expiry.
+ if newLease.ExpiryNs-nowNs < int64(time.Minute) {
+ ttl := plat.StickyTTLNs
+ if ttl <= 0 {
+ ttl = int64(24 * time.Hour)
+ }
+ newLease.ExpiryNs = nowNs + ttl
+ }
+
r.emitLeaseEvent(LeaseEvent{
Type: LeaseReplace,
PlatformID: plat.ID,
diff --git a/internal/service/control_plane_data.go b/internal/service/control_plane_data.go
new file mode 100644
index 0000000..dc29529
--- /dev/null
+++ b/internal/service/control_plane_data.go
@@ -0,0 +1,360 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/Resinat/Resin/internal/platform"
+)
+
+// ------------------------------------------------------------------
+// Export / Import data types
+// ------------------------------------------------------------------
+
+const exportVersion = 1
+
+// ExportPlatformEntry is the portable representation of a platform.
+type ExportPlatformEntry struct {
+ Name string `json:"name"`
+ StickyTTL string `json:"sticky_ttl"`
+ RegexFilters []string `json:"regex_filters"`
+ RegionFilters []string `json:"region_filters"`
+ AllocationPolicy string `json:"allocation_policy"`
+ ReverseProxyMissAction string `json:"reverse_proxy_miss_action"`
+ ReverseProxyEmptyAccountBehavior string `json:"reverse_proxy_empty_account_behavior"`
+ ReverseProxyFixedAccountHeader string `json:"reverse_proxy_fixed_account_header"`
+}
+
+// ExportSubscriptionEntry is the portable representation of a subscription.
+type ExportSubscriptionEntry struct {
+ Name string `json:"name"`
+ SourceType string `json:"source_type"`
+ URL string `json:"url"`
+ Content string `json:"content"`
+ UpdateInterval string `json:"update_interval"`
+ Enabled bool `json:"enabled"`
+ Ephemeral bool `json:"ephemeral"`
+ EphemeralNodeEvictDelay string `json:"ephemeral_node_evict_delay"`
+}
+
+// ExportPayload is the top-level JSON structure for data export/import.
+type ExportPayload struct {
+ Version int `json:"version"`
+ ExportedAt string `json:"exported_at"`
+ Platforms []ExportPlatformEntry `json:"platforms"`
+ Subscriptions []ExportSubscriptionEntry `json:"subscriptions"`
+}
+
+// ImportResult summarises what happened during an import.
+type ImportResult struct {
+ PlatformsCreated int `json:"platforms_created"`
+ PlatformsSkipped int `json:"platforms_skipped"`
+ PlatformsOverwritten int `json:"platforms_overwritten"`
+ SubscriptionsCreated int `json:"subscriptions_created"`
+ SubscriptionsSkipped int `json:"subscriptions_skipped"`
+ SubscriptionsOverwritten int `json:"subscriptions_overwritten"`
+ Errors []string `json:"errors"`
+}
+
+// ------------------------------------------------------------------
+// Export
+// ------------------------------------------------------------------
+
+// ExportData builds an ExportPayload containing all user-created platforms
+// and all subscriptions.
+func (s *ControlPlaneService) ExportData() (*ExportPayload, error) {
+ // --- platforms (exclude Default) ---
+ platforms, err := s.Engine.ListPlatforms()
+ if err != nil {
+ return nil, internal("list platforms for export", err)
+ }
+
+ exportPlatforms := make([]ExportPlatformEntry, 0, len(platforms))
+ for _, p := range platforms {
+ if p.ID == platform.DefaultPlatformID {
+ continue
+ }
+ resp := platformToResponse(p)
+ exportPlatforms = append(exportPlatforms, ExportPlatformEntry{
+ Name: resp.Name,
+ StickyTTL: resp.StickyTTL,
+ RegexFilters: resp.RegexFilters,
+ RegionFilters: resp.RegionFilters,
+ AllocationPolicy: resp.AllocationPolicy,
+ ReverseProxyMissAction: resp.ReverseProxyMissAction,
+ ReverseProxyEmptyAccountBehavior: resp.ReverseProxyEmptyAccountBehavior,
+ ReverseProxyFixedAccountHeader: resp.ReverseProxyFixedAccountHeader,
+ })
+ }
+
+ // --- subscriptions ---
+ subs, err := s.ListSubscriptions(nil)
+ if err != nil {
+ return nil, internal("list subscriptions for export", err)
+ }
+
+ exportSubs := make([]ExportSubscriptionEntry, 0, len(subs))
+ for _, sub := range subs {
+ exportSubs = append(exportSubs, ExportSubscriptionEntry{
+ Name: sub.Name,
+ SourceType: sub.SourceType,
+ URL: sub.URL,
+ Content: sub.Content,
+ UpdateInterval: sub.UpdateInterval,
+ Enabled: sub.Enabled,
+ Ephemeral: sub.Ephemeral,
+ EphemeralNodeEvictDelay: sub.EphemeralNodeEvictDelay,
+ })
+ }
+
+ return &ExportPayload{
+ Version: exportVersion,
+ ExportedAt: time.Now().UTC().Format(time.RFC3339),
+ Platforms: exportPlatforms,
+ Subscriptions: exportSubs,
+ }, nil
+}
+
+// ------------------------------------------------------------------
+// Import
+// ------------------------------------------------------------------
+
+// ImportData imports platforms and subscriptions from the given payload.
+// strategy must be "skip" (default) or "overwrite".
+func (s *ControlPlaneService) ImportData(payload ExportPayload, strategy string) (*ImportResult, error) {
+ if strategy == "" {
+ strategy = "skip"
+ }
+ if strategy != "skip" && strategy != "overwrite" {
+ return nil, invalidArg("strategy must be 'skip' or 'overwrite'")
+ }
+
+ result := &ImportResult{Errors: []string{}}
+
+ // ----- import platforms -----
+ s.importPlatforms(payload.Platforms, strategy, result)
+
+ // ----- import subscriptions -----
+ s.importSubscriptions(payload.Subscriptions, strategy, result)
+
+ return result, nil
+}
+
+func (s *ControlPlaneService) importPlatforms(entries []ExportPlatformEntry, strategy string, result *ImportResult) {
+ // Build existing name→id lookup.
+ existing, err := s.Engine.ListPlatforms()
+ if err != nil {
+ result.Errors = append(result.Errors, "failed to list existing platforms: "+err.Error())
+ return
+ }
+ nameToID := make(map[string]string, len(existing))
+ for _, p := range existing {
+ nameToID[p.Name] = p.ID
+ }
+
+ // Detect duplicates inside the import payload itself.
+ seen := make(map[string]bool, len(entries))
+
+ for i, entry := range entries {
+ name := strings.TrimSpace(entry.Name)
+ if name == "" {
+ result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: name is empty, skipped", i))
+ continue
+ }
+ if seen[name] {
+ result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: duplicate name %q in import payload, skipped", i, name))
+ continue
+ }
+ seen[name] = true
+
+ existingID, exists := nameToID[name]
+ if exists && strategy == "skip" {
+ result.PlatformsSkipped++
+ continue
+ }
+
+ if exists && strategy == "overwrite" {
+ // Overwrite: build a patch JSON and call UpdatePlatform.
+ patch := buildPlatformPatch(entry)
+ patchJSON, _ := json.Marshal(patch)
+ if _, err := s.UpdatePlatform(existingID, patchJSON); err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("platform %q: overwrite failed: %v", name, err))
+ continue
+ }
+ result.PlatformsOverwritten++
+ continue
+ }
+
+ // Create new platform.
+ req := buildCreatePlatformRequest(entry)
+ if _, err := s.CreatePlatform(req); err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("platform %q: create failed: %v", name, err))
+ continue
+ }
+ result.PlatformsCreated++
+ }
+}
+
+func (s *ControlPlaneService) importSubscriptions(entries []ExportSubscriptionEntry, strategy string, result *ImportResult) {
+ // Build existing name→id and url→id lookup tables.
+ existingSubs, err := s.ListSubscriptions(nil)
+ if err != nil {
+ result.Errors = append(result.Errors, "failed to list existing subscriptions: "+err.Error())
+ return
+ }
+ nameToID := make(map[string]string, len(existingSubs))
+ urlToID := make(map[string]string, len(existingSubs))
+ for _, sub := range existingSubs {
+ nameToID[sub.Name] = sub.ID
+ if sub.URL != "" {
+ urlToID[sub.URL] = sub.ID
+ }
+ }
+
+ // Detect duplicates inside the import payload itself.
+ seenName := make(map[string]bool, len(entries))
+ seenURL := make(map[string]bool, len(entries))
+
+ for i, entry := range entries {
+ name := strings.TrimSpace(entry.Name)
+ if name == "" {
+ result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: name is empty, skipped", i))
+ continue
+ }
+
+ // Internal dedup by name.
+ if seenName[name] {
+ result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate name %q in import payload, skipped", i, name))
+ continue
+ }
+ seenName[name] = true
+
+ // Internal dedup by URL for remote subs.
+ url := strings.TrimSpace(entry.URL)
+ if entry.SourceType == "remote" && url != "" {
+ if seenURL[url] {
+ result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate url %q in import payload, skipped", i, url))
+ continue
+ }
+ seenURL[url] = true
+ }
+
+ // Match against existing: first by name, then by URL.
+ existingID := ""
+ if id, ok := nameToID[name]; ok {
+ existingID = id
+ } else if entry.SourceType == "remote" && url != "" {
+ if id, ok := urlToID[url]; ok {
+ existingID = id
+ }
+ }
+
+ if existingID != "" && strategy == "skip" {
+ result.SubscriptionsSkipped++
+ continue
+ }
+
+ if existingID != "" && strategy == "overwrite" {
+ patch := buildSubscriptionPatch(entry)
+ patchJSON, _ := json.Marshal(patch)
+ if _, err := s.UpdateSubscription(existingID, patchJSON); err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: overwrite failed: %v", name, err))
+ continue
+ }
+ result.SubscriptionsOverwritten++
+ continue
+ }
+
+ // Create new subscription.
+ req := buildCreateSubscriptionRequest(entry)
+ if _, err := s.CreateSubscription(req); err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: create failed: %v", name, err))
+ continue
+ }
+ result.SubscriptionsCreated++
+ }
+}
+
+// ------------------------------------------------------------------
+// helpers: build request structs from export entries
+// ------------------------------------------------------------------
+
+func buildCreatePlatformRequest(e ExportPlatformEntry) CreatePlatformRequest {
+ name := strings.TrimSpace(e.Name)
+ return CreatePlatformRequest{
+ Name: &name,
+ StickyTTL: strPtr(e.StickyTTL),
+ RegexFilters: e.RegexFilters,
+ RegionFilters: e.RegionFilters,
+ AllocationPolicy: strPtr(e.AllocationPolicy),
+ ReverseProxyMissAction: strPtr(e.ReverseProxyMissAction),
+ ReverseProxyEmptyAccountBehavior: strPtr(e.ReverseProxyEmptyAccountBehavior),
+ ReverseProxyFixedAccountHeader: strPtr(e.ReverseProxyFixedAccountHeader),
+ }
+}
+
+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": 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,
+ }
+ return patch
+}
+
+func buildCreateSubscriptionRequest(e ExportSubscriptionEntry) CreateSubscriptionRequest {
+ name := strings.TrimSpace(e.Name)
+ sourceType := e.SourceType
+ url := strings.TrimSpace(e.URL)
+ content := e.Content
+ enabled := e.Enabled
+ ephemeral := e.Ephemeral
+ return CreateSubscriptionRequest{
+ Name: &name,
+ SourceType: &sourceType,
+ URL: &url,
+ Content: &content,
+ UpdateInterval: strPtr(e.UpdateInterval),
+ Enabled: &enabled,
+ Ephemeral: &ephemeral,
+ EphemeralNodeEvictDelay: strPtr(e.EphemeralNodeEvictDelay),
+ }
+}
+
+func buildSubscriptionPatch(e ExportSubscriptionEntry) map[string]any {
+ patch := map[string]any{
+ "name": strings.TrimSpace(e.Name),
+ "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
+}
+
+func strPtr(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
diff --git a/internal/service/control_plane_leases.go b/internal/service/control_plane_leases.go
index 1702e2b..6f9f6e5 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/index.html b/webui/index.html
index 939be23..5edaa66 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -7,6 +7,15 @@
Resin · Sticky Proxy Pool
+
diff --git a/webui/package-lock.json b/webui/package-lock.json
index 88505cf..96b1015 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/components/AppShell.tsx b/webui/src/components/AppShell.tsx
index 6e49f9c..0d8fe42 100644
--- a/webui/src/components/AppShell.tsx
+++ b/webui/src/components/AppShell.tsx
@@ -20,6 +20,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;
@@ -151,6 +152,7 @@ export function AppShell() {
) : (
)}
+
diff --git a/webui/src/components/ThemeToggle.tsx b/webui/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..eb591c6
--- /dev/null
+++ b/webui/src/components/ThemeToggle.tsx
@@ -0,0 +1,32 @@
+import { Monitor, Moon, Sun } from "lucide-react";
+import { cn } from "../lib/cn";
+import { useThemeStore } from "../stores/theme-store";
+import { useI18n } from "../i18n";
+
+type ThemeToggleProps = {
+ className?: string;
+};
+
+export function ThemeToggle({ className }: ThemeToggleProps) {
+ const { t } = useI18n();
+ const mode = useThemeStore((s) => s.mode);
+ const cycleMode = useThemeStore((s) => s.cycleMode);
+
+ const icon =
+ mode === "light" ? : mode === "dark" ? : ;
+
+ const label = mode === "light" ? t("浅色") : mode === "dark" ? t("深色") : t("系统");
+
+ return (
+
+ );
+}
diff --git a/webui/src/components/ui/Switch.css b/webui/src/components/ui/Switch.css
index e33972e..3cb58dc 100644
--- a/webui/src/components/ui/Switch.css
+++ b/webui/src/components/ui/Switch.css
@@ -52,3 +52,12 @@
opacity: 0.5;
cursor: not-allowed;
}
+
+/* ── Dark theme ── */
+[data-theme="dark"] .switch-slider {
+ background-color: rgba(130, 170, 220, 0.18);
+}
+
+[data-theme="dark"] .switch-slider:before {
+ background-color: #c8d4e4;
+}
diff --git a/webui/src/features/platforms/PlatformDetailPage.tsx b/webui/src/features/platforms/PlatformDetailPage.tsx
index bb93d69..debae00 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: "重置、清租约、删除操作" },
];
@@ -311,6 +313,17 @@ export function PlatformDetailPage() {
) : null}
+ {activeTab === "leases" ? (
+
+ ) : null}
+
{activeTab === "config" ? (
)}
-
-
+
+
@@ -987,7 +1148,6 @@ export function SystemConfigPage() {
-
)}
diff --git a/webui/src/features/systemConfig/api.ts b/webui/src/features/systemConfig/api.ts
index 33357a5..952c04e 100644
--- a/webui/src/features/systemConfig/api.ts
+++ b/webui/src/features/systemConfig/api.ts
@@ -1,4 +1,5 @@
-import { apiRequest } from "../../lib/api-client";
+import { getStoredAuthToken } from "../auth/auth-store";
+import { apiRequest, type JsonValue } from "../../lib/api-client";
import type { EnvConfig, RuntimeConfig, RuntimeConfigPatch } from "./types";
const path = "/api/v1/system/config";
@@ -103,3 +104,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 JsonValue,
+ });
+}
diff --git a/webui/src/i18n/translations.ts b/webui/src/i18n/translations.ts
index 3d98f35..ece55fb 100644
--- a/webui/src/i18n/translations.ts
+++ b/webui/src/i18n/translations.ts
@@ -8,6 +8,10 @@ const EXACT_ZH_TO_EN: Record = {
"高性能粘性代理池 · 管理面板": "High-performance sticky proxy pool · Admin Console",
"主导航": "Main Navigation",
"切换语言": "Switch Language",
+ "切换主题": "Toggle Theme",
+ "深色": "Dark",
+ "浅色": "Light",
+ "系统": "Auto",
"安全警告": "Security Warning",
"退出登录": "Sign Out",
"当前为免认证访问模式": "Running in no-auth mode",
@@ -624,6 +628,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 {
diff --git a/webui/src/lib/api-client.ts b/webui/src/lib/api-client.ts
index 085a0c5..4a1de2f 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?: {
diff --git a/webui/src/lib/time.ts b/webui/src/lib/time.ts
index a298ff3..b85c414 100644
--- a/webui/src/lib/time.ts
+++ b/webui/src/lib/time.ts
@@ -106,7 +106,9 @@ export function formatRelativeTime(input: string | null | undefined, emptyLabel
}
const now = new Date();
- const diff = Math.max(0, now.getTime() - time.getTime());
+ const rawDiff = now.getTime() - time.getTime();
+ const isFuture = rawDiff < 0;
+ const diff = Math.abs(rawDiff);
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
@@ -116,18 +118,19 @@ export function formatRelativeTime(input: string | null | undefined, emptyLabel
const years = Math.floor(days / 365);
if (english) {
- if (years > 0) return `${years} year${years === 1 ? "" : "s"} ago`;
- if (months > 0) return `${months} month${months === 1 ? "" : "s"} ago`;
- if (days > 0) return `${days} day${days === 1 ? "" : "s"} ago`;
- if (hours > 0) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
- if (minutes > 0) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
+ if (years > 0) return isFuture ? `in ${years} year${years === 1 ? "" : "s"}` : `${years} year${years === 1 ? "" : "s"} ago`;
+ if (months > 0) return isFuture ? `in ${months} month${months === 1 ? "" : "s"}` : `${months} month${months === 1 ? "" : "s"} ago`;
+ if (days > 0) return isFuture ? `in ${days} day${days === 1 ? "" : "s"}` : `${days} day${days === 1 ? "" : "s"} ago`;
+ if (hours > 0) return isFuture ? `in ${hours} hour${hours === 1 ? "" : "s"}` : `${hours} hour${hours === 1 ? "" : "s"} ago`;
+ if (minutes > 0) return isFuture ? `in ${minutes} minute${minutes === 1 ? "" : "s"}` : `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
return "just now";
}
- if (years > 0) return `${years} 年前`;
- if (months > 0) return `${months} 个月前`;
- if (days > 0) return `${days} 天前`;
- if (hours > 0) return `${hours} 小时前`;
- if (minutes > 0) return `${minutes} 分钟前`;
+ const suffix = isFuture ? "后" : "前";
+ if (years > 0) return `${years} 年${suffix}`;
+ if (months > 0) return `${months} 个月${suffix}`;
+ if (days > 0) return `${days} 天${suffix}`;
+ if (hours > 0) return `${hours} 小时${suffix}`;
+ if (minutes > 0) return `${minutes} 分钟${suffix}`;
return "刚刚";
}
diff --git a/webui/src/stores/theme-store.ts b/webui/src/stores/theme-store.ts
new file mode 100644
index 0000000..a1a3fe1
--- /dev/null
+++ b/webui/src/stores/theme-store.ts
@@ -0,0 +1,70 @@
+import { create } from "zustand";
+
+type ThemeMode = "system" | "light" | "dark";
+type ResolvedTheme = "light" | "dark";
+
+const STORAGE_KEY = "resin_theme";
+
+function resolveTheme(mode: ThemeMode): ResolvedTheme {
+ if (mode === "light" || mode === "dark") return mode;
+ if (typeof window === "undefined") return "light";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+}
+
+function applyTheme(resolved: ResolvedTheme): void {
+ if (typeof document !== "undefined") {
+ document.documentElement.setAttribute("data-theme", resolved);
+ }
+}
+
+function loadInitialMode(): ThemeMode {
+ if (typeof window === "undefined") return "system";
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ if (stored === "light" || stored === "dark") return stored;
+ return "system";
+}
+
+type ThemeState = {
+ mode: ThemeMode;
+ resolved: ResolvedTheme;
+ setMode: (mode: ThemeMode) => void;
+ cycleMode: () => void;
+};
+
+const initialMode = loadInitialMode();
+const initialResolved = resolveTheme(initialMode);
+applyTheme(initialResolved);
+
+export const useThemeStore = create((set, get) => ({
+ mode: initialMode,
+ resolved: initialResolved,
+ setMode: (mode) => {
+ const resolved = resolveTheme(mode);
+ if (typeof window !== "undefined") {
+ if (mode === "system") {
+ window.localStorage.removeItem(STORAGE_KEY);
+ } else {
+ window.localStorage.setItem(STORAGE_KEY, mode);
+ }
+ }
+ applyTheme(resolved);
+ set({ mode, resolved });
+ },
+ cycleMode: () => {
+ const order: ThemeMode[] = ["system", "light", "dark"];
+ const current = get().mode;
+ const next = order[(order.indexOf(current) + 1) % order.length];
+ get().setMode(next);
+ },
+}));
+
+// Listen for system theme changes when in "system" mode
+if (typeof window !== "undefined") {
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
+ mql.addEventListener("change", () => {
+ const { mode, setMode } = useThemeStore.getState();
+ if (mode === "system") {
+ setMode("system"); // re-resolve
+ }
+ });
+}
diff --git a/webui/src/styles/theme.css b/webui/src/styles/theme.css
index c2e785e..85f25bf 100644
--- a/webui/src/styles/theme.css
+++ b/webui/src/styles/theme.css
@@ -2611,6 +2611,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;
@@ -3074,3 +3146,450 @@ a {
transform: translateY(-20px) scale(0.92);
}
}
+
+/* ════════════════════════════════════════════
+ Dark theme
+ ════════════════════════════════════════════ */
+
+[data-theme="dark"] {
+ color-scheme: dark;
+
+ --bg: #0e1420;
+ --bg-soft: #131b29;
+ --surface: rgba(22, 33, 52, 0.86);
+ --surface-strong: #1a2538;
+ --surface-sunken: rgba(255, 255, 255, 0.04);
+ --text: #e1e7ef;
+ --text-muted: #8295b0;
+ --border: rgba(130, 170, 220, 0.14);
+ --shadow: 0 18px 40px rgba(0, 0, 0, 0.32);
+ --primary: #3b8eff;
+ --primary-strong: #5ca3ff;
+ --primary-soft: rgba(59, 142, 255, 0.18);
+ --success: #2dcc9f;
+ --danger: #f06b5e;
+ --warning: #eab035;
+}
+
+/* ── Body background ── */
+[data-theme="dark"] body {
+ background:
+ radial-gradient(circle at 16% 22%, rgba(30, 90, 180, 0.12), transparent 36%),
+ radial-gradient(circle at 85% 0%, rgba(200, 120, 40, 0.08), transparent 42%),
+ linear-gradient(180deg, #0e1420, #111a28 60%, #0f1724);
+}
+
+/* ── Sidebar ── */
+[data-theme="dark"] .sidebar {
+ background: linear-gradient(180deg, rgba(16, 24, 40, 0.95), rgba(14, 20, 32, 0.98));
+}
+
+[data-theme="dark"] .brand-logo {
+ background: #1a2538;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+[data-theme="dark"] .nav-item {
+ color: #c0cede;
+}
+
+[data-theme="dark"] .nav-item:hover {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+[data-theme="dark"] .nav-item-active {
+ background: rgba(59, 142, 255, 0.12);
+ border-color: rgba(59, 142, 255, 0.25);
+ color: var(--primary-strong);
+}
+
+/* ── Buttons ── */
+[data-theme="dark"] .btn-secondary {
+ background: rgba(255, 255, 255, 0.06);
+ color: #c8d4e4;
+}
+
+[data-theme="dark"] .btn-secondary:hover:enabled,
+[data-theme="dark"] .btn-ghost:hover:enabled {
+ background: rgba(59, 142, 255, 0.14);
+ color: #7bb8ff;
+}
+
+[data-theme="dark"] .btn-ghost {
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text-muted);
+}
+
+/* ── Language & Theme switcher ── */
+[data-theme="dark"] .locale-switch {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+[data-theme="dark"] .locale-switch-compact {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+[data-theme="dark"] .locale-switch-compact:hover {
+ background: rgba(59, 142, 255, 0.14);
+}
+
+/* ── Badges ── */
+[data-theme="dark"] .badge-neutral {
+ color: #c0cede;
+ background: rgba(130, 170, 220, 0.12);
+}
+
+[data-theme="dark"] .badge-success {
+ color: #2dcc9f;
+ background: rgba(45, 204, 159, 0.14);
+}
+
+[data-theme="dark"] .badge-warning {
+ color: #eab035;
+ background: rgba(234, 176, 53, 0.14);
+}
+
+[data-theme="dark"] .badge-danger {
+ color: #f06b5e;
+ background: rgba(240, 107, 94, 0.14);
+}
+
+[data-theme="dark"] .badge-info {
+ color: #5ca3ff;
+ background: rgba(59, 142, 255, 0.14);
+}
+
+[data-theme="dark"] .badge-accent {
+ color: #c084fc;
+ background: rgba(192, 132, 252, 0.14);
+}
+
+[data-theme="dark"] .badge-muted {
+ color: #8295b0;
+ background: rgba(130, 149, 176, 0.12);
+}
+
+/* ── Callouts ── */
+[data-theme="dark"] .callout-success {
+ color: #2dcc9f;
+ border-color: rgba(45, 204, 159, 0.25);
+ background: rgba(45, 204, 159, 0.08);
+}
+
+[data-theme="dark"] .callout-error {
+ color: #f06b5e;
+ border-color: rgba(240, 107, 94, 0.25);
+ background: rgba(240, 107, 94, 0.08);
+}
+
+[data-theme="dark"] .callout-warning {
+ color: #eab035;
+ border-color: rgba(234, 176, 53, 0.25);
+ background: rgba(234, 176, 53, 0.08);
+}
+
+/* ── Toasts ── */
+[data-theme="dark"] .toast-success {
+ color: #2dcc9f;
+ border-color: rgba(45, 204, 159, 0.3);
+ background: rgba(18, 42, 36, 0.95);
+}
+
+[data-theme="dark"] .toast-error {
+ color: #f06b5e;
+ border-color: rgba(240, 107, 94, 0.3);
+ background: rgba(42, 22, 22, 0.95);
+}
+
+/* ── Table headers ── */
+[data-theme="dark"] .nodes-table th,
+[data-theme="dark"] .logs-table th,
+[data-theme="dark"] .rules-table th,
+[data-theme="dark"] .geoip-table th,
+[data-theme="dark"] .data-table th {
+ background: rgba(18, 28, 44, 0.95);
+ color: #8295b0;
+}
+
+/* ── Table wraps ── */
+[data-theme="dark"] .nodes-table-wrap,
+[data-theme="dark"] .logs-table-wrap,
+[data-theme="dark"] .rules-table-wrap,
+[data-theme="dark"] .geoip-table-wrap,
+[data-theme="dark"] .data-table-wrap {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+/* ── Cards & containers with white backgrounds ── */
+[data-theme="dark"] .platform-row {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .platform-tile {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .platform-fact {
+ background: rgba(255, 255, 255, 0.05);
+ color: #8295b0;
+}
+
+[data-theme="dark"] .platform-fact strong {
+ color: #7bb8ff;
+}
+
+[data-theme="dark"] .syscfg-section {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .geoip-kv {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .geoip-result {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .stats-grid > div {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .tag-item {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .dashboard-snapshot-list > div {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .platform-drawer-section {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .platform-monitor-snapshot-list > div {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+[data-theme="dark"] .platform-op-item {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .platform-leases-bind-form {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .logs-detail-block {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .resolve-result {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+[data-theme="dark"] .empty-box {
+ background: rgba(255, 255, 255, 0.03);
+ border-color: rgba(130, 170, 220, 0.15);
+}
+
+/* ── Tabs ── */
+[data-theme="dark"] .platform-detail-tabs,
+[data-theme="dark"] .logs-payload-tabs {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+[data-theme="dark"] .platform-detail-tab {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .platform-detail-tab:hover:not(.platform-detail-tab-active) {
+ color: #c8d4e4;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+[data-theme="dark"] .platform-detail-tab-active {
+ color: #e1e7ef;
+ background: rgba(59, 142, 255, 0.14);
+ box-shadow: none;
+}
+
+[data-theme="dark"] .payload-tab {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .payload-tab:hover:not(.payload-tab-active) {
+ color: #c8d4e4;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+[data-theme="dark"] .payload-tab-active {
+ color: #e1e7ef;
+ background: rgba(59, 142, 255, 0.14);
+ box-shadow: none;
+}
+
+/* ── Charts ── */
+[data-theme="dark"] .trend-svg {
+ background:
+ linear-gradient(180deg, rgba(14, 20, 32, 0.9), rgba(18, 28, 44, 0.95)),
+ repeating-linear-gradient(90deg, transparent, transparent 42px, rgba(130, 170, 220, 0.06) 42px, rgba(130, 170, 220, 0.06) 43px);
+ border-color: rgba(130, 170, 220, 0.12);
+}
+
+[data-theme="dark"] .histogram-chart {
+ background:
+ linear-gradient(180deg, rgba(14, 20, 32, 0.9), rgba(18, 28, 44, 0.95)),
+ repeating-linear-gradient(0deg, transparent, transparent 28px, rgba(130, 170, 220, 0.06) 28px, rgba(130, 170, 220, 0.06) 29px);
+ border-color: rgba(130, 170, 220, 0.12);
+}
+
+/* ── Tooltips ── */
+[data-theme="dark"] .trend-tooltip {
+ background: rgba(18, 28, 44, 0.96);
+ border-color: rgba(130, 170, 220, 0.2);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
+}
+
+[data-theme="dark"] .trend-tooltip-time {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .trend-tooltip-row {
+ color: #c0cede;
+}
+
+[data-theme="dark"] .trend-tooltip-row b {
+ color: #e1e7ef;
+}
+
+[data-theme="dark"] .histogram-tooltip {
+ background: rgba(18, 28, 44, 0.96);
+ border-color: rgba(130, 170, 220, 0.2);
+ box-shadow: 0 8px 18px rgba(0, 0, 0, 0.3);
+}
+
+[data-theme="dark"] .histogram-tooltip-title {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .histogram-tooltip-value {
+ color: #e1e7ef;
+}
+
+/* ── Text color overrides (hardcoded grays) ── */
+[data-theme="dark"] .eyebrow {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .topbar-pill {
+ color: #2dcc9f;
+ border-color: rgba(45, 204, 159, 0.22);
+ background: rgba(45, 204, 159, 0.1);
+}
+
+[data-theme="dark"] .dashboard-legend {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .dashboard-summary-inline {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .dashboard-hist-title {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .dashboard-control > span {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .trend-footer {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .field-label {
+ color: #c0cede;
+}
+
+[data-theme="dark"] .field-error {
+ color: #f06b5e;
+}
+
+[data-theme="dark"] .subscription-inline-filter > span {
+ color: #c0cede;
+}
+
+[data-theme="dark"] .subscription-switch-label {
+ color: #c0cede;
+}
+
+[data-theme="dark"] .nodes-cell-hash {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .table-sort-btn > span {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .tag-item code {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .logs-detail-block code {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .platform-monitor-kpi-value {
+ color: #e1e7ef;
+}
+
+[data-theme="dark"] .platform-monitor-kpi-sub {
+ color: #8295b0;
+}
+
+[data-theme="dark"] .platform-monitor-snapshot-list p {
+ color: #e1e7ef;
+}
+
+/* ── Code blocks ── */
+[data-theme="dark"] .syscfg-preview {
+ background: #131b29;
+}
+
+[data-theme="dark"] .logs-payload-box {
+ background: #131b29;
+}
+
+/* ── Modal / Drawer overlays ── */
+[data-theme="dark"] .modal-overlay {
+ background: rgba(0, 0, 0, 0.5);
+}
+
+[data-theme="dark"] .drawer-overlay {
+ background: rgba(0, 0, 0, 0.46);
+}
+
+/* ── Platform monitor section ── */
+[data-theme="dark"] .platform-monitor-section {
+ border-color: rgba(130, 170, 220, 0.14);
+ background:
+ linear-gradient(180deg, rgba(14, 20, 32, 0.9), rgba(18, 28, 44, 0.95)),
+ radial-gradient(circle at 6% 12%, rgba(59, 142, 255, 0.08), transparent 46%);
+}
+
+/* ── Platform directory / cards container gradient ── */
+[data-theme="dark"] .platform-directory-card,
+[data-theme="dark"] .platform-cards-container {
+ background:
+ radial-gradient(circle at 12% 4%, rgba(59, 142, 255, 0.08), transparent 36%),
+ radial-gradient(circle at 88% 100%, rgba(45, 204, 159, 0.05), transparent 40%),
+ var(--surface);
+}
+
+/* ── Input disabled / readonly ── */
+[data-theme="dark"] .input:disabled,
+[data-theme="dark"] .select:disabled,
+[data-theme="dark"] .textarea:disabled,
+[data-theme="dark"] .input:read-only,
+[data-theme="dark"] .textarea:read-only {
+ background: rgba(255, 255, 255, 0.03);
+}