diff --git a/.gitea/workflows/build-web-image.yml b/.gitea/workflows/build-web-image.yml new file mode 100644 index 0000000..cf54607 --- /dev/null +++ b/.gitea/workflows/build-web-image.yml @@ -0,0 +1,62 @@ +--- +name: Build web service image + +"on": + push: + branches: + - main + tags: + - "v*" + paths: + - "control-plane/**" + - ".gitea/workflows/build-web-image.yml" + workflow_dispatch: + +env: + REGISTRY: git.ipao.vip + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + + - name: Log in to Gitea container registry + env: + REGISTRY_USERNAME: ${{ github.repository_owner }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + if [ -z "$REGISTRY_TOKEN" ]; then + echo "REGISTRY_TOKEN is not configured" >&2 + exit 1 + fi + printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" \ + --username "$REGISTRY_USERNAME" --password-stdin + + - name: Build and push image + shell: bash + run: | + set -euo pipefail + image="$REGISTRY/$IMAGE_NAME" + sha_tag="sha-${GITHUB_SHA:0:12}" + + docker build \ + --file control-plane/Dockerfile \ + --tag "$image:$sha_tag" \ + control-plane + docker push "$image:$sha_tag" + + case "$GITHUB_REF" in + refs/heads/main) + docker tag "$image:$sha_tag" "$image:latest" + docker push "$image:latest" + ;; + refs/tags/v*) + release_tag="${GITHUB_REF#refs/tags/}" + docker tag "$image:$sha_tag" "$image:$release_tag" + docker push "$image:$release_tag" + ;; + esac diff --git a/.gitignore b/.gitignore index 9d40a2e..bfda0e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ **/bin/ **/obj/ +**/node_modules/ artifacts/ TestResults/ coverage/ diff --git a/README.md b/README.md index 9d6372b..ebfa2a8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ WxAgent 使用 C#/.NET 8 独立实现微信 Windows 桌面端自动化能力。 - `node-agent/`:Windows 节点端,C#/.NET 8;包含 UIA、Win32、Desktop Agent、CLI、托盘和节点本地服务兼容层。 - `control-plane/`:远程中心控制面,Go;Go 测试与实现代码同目录。 - `tests/node-agent/`:节点端 .NET 测试及只读 SQL 离线检查。 +- 远程控制面架构、部署、验证与限制见 [`docs/WxAgent-远程控制面架构与部署说明-v1.0.md`](docs/WxAgent-远程控制面架构与部署说明-v1.0.md)。 ## 当前能力 @@ -19,6 +20,7 @@ WxAgent 使用 C#/.NET 8 独立实现微信 Windows 桌面端自动化能力。 - `chat reply-latest`:通过消息行实时边界右侧气泡定位执行引用回复;解析微信 `回复文本\n引用 <发送者> 的消息 : <原文>` 格式。 - `chat send-file/send-image`:使用剪贴板粘贴发送文件或真实图片数据,不打开文件选择框,并恢复常见文本、图片或文件剪贴板内容。 - `smoke`:执行 `doctor`、脱敏 UI 树导出,并向文件传输助手发送唯一 ASCII 标记后读回确认。 +- 远程控制面支持节点注册、心跳、任务队列、`send-text` 以及受本地白名单保护的会话/联系人/消息只读任务;当前实现与部署方式见架构说明文档。 - 文本输入使用 UIA ValuePattern 直接设置并在发送前逐字校验,避免中文输入法组合态改变消息内容。 - `db scan`:以 `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` 扫描 WCDB 十六进制候选,只保存通过具体数据库 page 1 HMAC-SHA512 验证的密钥。 - `db query`:通过 SQLCipher 4 以文件系统 ReadOnly、私有缓存、无连接池和 `query_only` 读取 `sqlite_master` 元数据,并确认写操作被拒绝。 diff --git a/WxAgent.sln b/WxAgent.sln index a9f16f8..2fb70e5 100644 --- a/WxAgent.sln +++ b/WxAgent.sln @@ -22,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Core.Tests", "tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Service.Tests", "tests\node-agent\WxAgent.Service.Tests\WxAgent.Service.Tests.csproj", "{99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WxAgent.Remote.Integration", "tests\node-agent\WxAgent.Remote.Integration\WxAgent.Remote.Integration.csproj", "{A9CE1BB7-F857-46B7-98F7-5C84216E3206}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,6 +61,10 @@ Global {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Debug|Any CPU.Build.0 = Debug|Any CPU {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Release|Any CPU.ActiveCfg = Release|Any CPU {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB}.Release|Any CPU.Build.0 = Release|Any CPU + {A9CE1BB7-F857-46B7-98F7-5C84216E3206}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9CE1BB7-F857-46B7-98F7-5C84216E3206}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9CE1BB7-F857-46B7-98F7-5C84216E3206}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9CE1BB7-F857-46B7-98F7-5C84216E3206}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {EDC68F82-2F67-40A5-B6CA-B4D26349E988} = {11E9B433-75E3-446D-B8FF-958A0F541BA5} @@ -69,5 +75,6 @@ Global {64E9FE15-8AE0-44A8-8240-4B8BC3E8823A} = {CE7C3E9D-F9B0-4A30-AA6F-A645D75888D9} {1F24FB68-6917-4984-89FC-5F9C64E2965D} = {64E9FE15-8AE0-44A8-8240-4B8BC3E8823A} {99273C3C-2BCE-431A-AEFA-EFBDEE5ECAFB} = {64E9FE15-8AE0-44A8-8240-4B8BC3E8823A} + {A9CE1BB7-F857-46B7-98F7-5C84216E3206} = {64E9FE15-8AE0-44A8-8240-4B8BC3E8823A} EndGlobalSection EndGlobal diff --git a/control-plane/.dockerignore b/control-plane/.dockerignore new file mode 100644 index 0000000..a5e60f3 --- /dev/null +++ b/control-plane/.dockerignore @@ -0,0 +1,7 @@ +.git +web/node_modules +web/dist +*.log +control-plane-data.json +web/.env* +artifacts diff --git a/control-plane/Dockerfile b/control-plane/Dockerfile new file mode 100644 index 0000000..029670e --- /dev/null +++ b/control-plane/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS web-build +WORKDIR /src/web +COPY web/package*.json ./ +RUN npm ci --no-audit --no-fund +COPY web/ ./ +RUN npm run build + +FROM golang:1.23-bookworm AS go-build +WORKDIR /src +COPY go.mod ./ +COPY . ./ +COPY --from=web-build /src/web/dist ./web/dist +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" \ + -o /out/wxagent-control-plane ./cmd/wxagent-control-plane +RUN mkdir /runtime-data + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=go-build /out/wxagent-control-plane /app/wxagent-control-plane +COPY --from=go-build --chown=65532:65532 /runtime-data /data +ENV WXAGENT_CONTROL_PLANE_ADDR=0.0.0.0:8090 \ + WXAGENT_CONTROL_PLANE_DATA=/data/control-plane-data.json +VOLUME ["/data"] +EXPOSE 8090 +ENTRYPOINT ["/app/wxagent-control-plane"] diff --git a/control-plane/README.md b/control-plane/README.md new file mode 100644 index 0000000..34d5824 --- /dev/null +++ b/control-plane/README.md @@ -0,0 +1,80 @@ +# WxAgent Control Plane + +Go 1.23 控制面与 React 管理端。节点只主动出站连接,控制面不暴露节点端口。 + +## 本地启动 + +```bash +cd control-plane +npm --prefix web ci --no-audit --no-fund +npm --prefix web run build +go run ./cmd/wxagent-control-plane +``` + +启动前设置(单节点兼容写法): + +```bash +export WXAGENT_NODE_ID=local-node +export WXAGENT_NODE_TOKEN='local-node-token' +export WXAGENT_WEB_USER=admin +export WXAGENT_WEB_PASSWORD='change-me' +``` + +多节点/多用户使用 JSON map: + +```bash +export WXAGENT_NODE_TOKENS='{"node-a":"token-a","node-b":"token-b"}' +export WXAGENT_WEB_USERS='{"admin":"change-me","auditor":"read-only-password"}' +``` + +可选:`WXAGENT_CONTROL_PLANE_ADDR`(默认 `127.0.0.1:8090`)、`WXAGENT_CONTROL_PLANE_DATA`(默认 `control-plane-data.json`)。Token 只从环境变量读取,不写入控制面数据文件和日志。节点 `service.json` 可设置 `remoteConfigurationFile` 指向 CLI 管理的 `remote.json`;修改上报配置会在下一轮生效,修改远程地址/Token 后需重启 Agent。 + +浏览器访问 `http://127.0.0.1:8090/`,登录后管理节点、任务、白名单事件和审计记录。生产部署必须使用 HTTPS 和外部密钥管理;本地 HTTP 仅用于 loopback 集成测试。 + +远程只读查询使用同一持久化任务队列,返回 `task_id` 后通过 `GET /v1/tasks/{task_id}` 取结果: + +```text +POST /v1/reads/sessions { node_id, account_id, idempotency_key, limit?, offset? } +POST /v1/reads/contacts { node_id, account_id, idempotency_key, groups_only, contains?, limit?, offset? } +POST /v1/reads/messages { node_id, account_id, idempotency_key, chat_id, include_content?, limit?, offset? } +``` + +节点只返回本地已启用且 `identityVerified` 的白名单会话;未授权范围在节点读取前拒绝。`chat_id` 必须使用会话列表返回的稳定会话标识,消息结果是当前微信 UI 可见历史,不是数据库全量历史。联系人查询中的 `chat_id` 使用联系人数据库稳定 ID;所有响应仍受任务结果大小和分页上限约束。 + +## 验证 + +```bash +cd control-plane +go test ./... +go vet ./... +cd ../control-plane/web +npm run build +cd ../.. +./scripts/remote-control-smoke.sh +``` + +`remote-control-smoke.sh` 会启动一次临时控制面,运行 .NET 节点协议客户端,验证注册、心跳、任务租约、幂等、结果回传和白名单拒绝;不会操作真实微信联系人。 + +## Windows 手工验收 + +1. 在已登录且未锁定的微信桌面会话中运行 `WxAgent.Host doctor` 和 `WxAgent.Host inspect-ui --output artifacts/ui-tree.json`,确认账号绑定使用稳定 `accountId`,会话使用稳定 `AutomationId`,不使用昵称/PID/窗口句柄猜测。 +2. 为节点配置 `remote.json`,例如先执行: + + ```powershell + WxAgent.Host remote auth set --config remote.json --address https://control.example --token $env:WXAGENT_NODE_TOKEN --node node-a --active-account <已确认的accountId> + WxAgent.Host remote reporting enable --config remote.json + WxAgent.Host remote reporting account-add --config remote.json --account <已确认的accountId> + WxAgent.Host remote reporting account-enable --config remote.json --account <已确认的accountId> + WxAgent.Host remote reporting allow --config remote.json --account <已确认的accountId> --type group --chat-id <已确认的稳定chatId> --identity-verified + WxAgent.Host remote probe run --config remote.json + ``` + +3. 在节点 `service.json` 设置 `"remoteConfigurationFile": ""` 后启动 `serve`。控制面应显示节点 `Online`、已验证账号和心跳版本。 +4. 仅使用文件传输助手、`Hao 豪`、`吉祥三宝`、`消息测试专用群组`做发送/接收验证:先验证允许的群聊事件能到达控制面,再执行 `reporting deny`,确认后续正文不再上传;创建 `send-text` 前确认目标账号、稳定 chat ID 和文本。 +5. 在控制面取消任务并验证节点不执行;断开网络后恢复,确认未完成副作用任务显示 `ResultUnconfirmed`,不会自动重放。 + +未解锁的 Session 0、昵称、PID、窗口句柄和未确认的运行时身份都应视为验收失败;手工验收不得改写微信数据库或绕过安全机制。 + +## 容器镜像 + +`.gitea/workflows/build-web-image.yml` 会构建并推送 `git.ipao.vip//` 镜像:主分支额外更新 `latest`,`v*` 标签额外更新对应版本标签。请在 Gitea 账号级 Secrets 配置 `REGISTRY_TOKEN`;登录用户名使用仓库所有者。 diff --git a/control-plane/cmd/wxagent-control-plane/main.go b/control-plane/cmd/wxagent-control-plane/main.go new file mode 100644 index 0000000..56c75bd --- /dev/null +++ b/control-plane/cmd/wxagent-control-plane/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + controlplane "git.ipao.vip/rogee/wx-win-agent/control-plane" +) + +func main() { + config := controlplane.DefaultServerConfig() + if value := os.Getenv("WXAGENT_CONTROL_PLANE_ADDR"); value != "" { + config.ListenAddr = value + } + if value := os.Getenv("WXAGENT_CONTROL_PLANE_DATA"); value != "" { + config.DataFile = value + } + config.NodeTokens = readMapEnv("WXAGENT_NODE_TOKENS") + if len(config.NodeTokens) == 0 { + nodeID, nodeToken := os.Getenv("WXAGENT_NODE_ID"), os.Getenv("WXAGENT_NODE_TOKEN") + if strings.TrimSpace(nodeID) != "" && nodeToken != "" { + config.NodeTokens = map[string]string{nodeID: nodeToken} + } + } + config.WebUsers = readMapEnv("WXAGENT_WEB_USERS") + if len(config.WebUsers) == 0 { + webUser, webPassword := os.Getenv("WXAGENT_WEB_USER"), os.Getenv("WXAGENT_WEB_PASSWORD") + if strings.TrimSpace(webUser) != "" && webPassword != "" { + config.WebUsers = map[string]string{webUser: webPassword} + } + } + if len(config.NodeTokens) == 0 || len(config.WebUsers) == 0 { + log.Fatal("configure WXAGENT_NODE_TOKENS/WXAGENT_WEB_USERS as JSON maps, or the single-node fallback environment variables") + } + + server, err := controlplane.NewServer(config) + if err != nil { + log.Fatal(err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + log.Printf("wxagent control plane listening on %s; data=%s", config.ListenAddr, config.DataFile) + if err := server.ListenAndServe(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func readMapEnv(name string) map[string]string { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return nil + } + var result map[string]string + if err := json.Unmarshal([]byte(value), &result); err != nil { + log.Fatalf("%s must be a JSON object", name) + } + return result +} diff --git a/control-plane/lease_test.go b/control-plane/lease_test.go new file mode 100644 index 0000000..d350565 --- /dev/null +++ b/control-plane/lease_test.go @@ -0,0 +1,97 @@ +package controlplane + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" +) + +func TestCancellationLeaseRenewalAndExpiredGeneration(t *testing.T) { + server, err := NewServer(ServerConfig{ + DataFile: filepath.Join(t.TempDir(), "state.json"), + NodeTokens: map[string]string{"node-1": "node-secret"}, + WebUsers: map[string]string{"admin": "web-secret"}, + LeaseTTL: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + client := httpServer.Client() + login := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/auth/login", "", map[string]string{"username": "admin", "password": "web-secret"}) + var session struct { + AccessToken string `json:"access_token"` + } + decodeBody(t, login, &session) + webAuth := "Bearer " + session.AccessToken + + registration := NodeRegistration{NodeID: "node-1", AgentVersion: "test", ProtocolVersion: ProtocolVersion, + Accounts: []AccountSummary{{AccountID: "account-a", Active: true, Verified: true}}} + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer node-secret", registration); response.Code != http.StatusOK { + t.Fatal(response.Code) + } + create := func(key string) TaskSubmissionResponse { + response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", webAuth, TaskSubmission{ + NodeID: "node-1", AccountID: "account-a", Kind: "send-text", IdempotencyKey: key, + Payload: json.RawMessage(`{"target_id":"target","text":"text","confirmed":true}`), + }) + var result TaskSubmissionResponse + decodeBody(t, response, &result) + return result + } + + cancelled := create("cancel-before-ack") + poll := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/node-1/tasks?account_id=account-a", "Bearer node-secret", nil) + var batch TaskBatch + decodeBody(t, poll, &batch) + if len(batch.Tasks) != 1 { + t.Fatalf("expected one task, got %d", len(batch.Tasks)) + } + lease := batch.Tasks[0] + cancel := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks/"+cancelled.TaskID+"/cancel", webAuth, nil) + var cancelledTask Task + decodeBody(t, cancel, &cancelledTask) + if cancelledTask.Status != TaskPending || cancelledTask.CancelRequestedAt == nil { + t.Fatalf("cancel mutated primary state: %+v", cancelledTask) + } + ack := TaskAck{TaskID: lease.TaskID, AccountID: lease.AccountID, LeaseGeneration: lease.LeaseGeneration} + ackResponse := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+lease.TaskID+"/ack", "Bearer node-secret", ack) + var acked Task + decodeBody(t, ackResponse, &acked) + if acked.Status != TaskCancelled { + t.Fatalf("cancel was not confirmed at safe ack point: %+v", acked) + } + + running := create("renew-and-stale") + poll = doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/node-1/tasks?account_id=account-a", "Bearer node-secret", nil) + batch = TaskBatch{} + decodeBody(t, poll, &batch) + var runningLease Task + for _, task := range batch.Tasks { + if task.TaskID == running.TaskID { + runningLease = task + } + } + if runningLease.TaskID == "" { + t.Fatal("running task was not offered") + } + ack = TaskAck{TaskID: runningLease.TaskID, AccountID: runningLease.AccountID, LeaseGeneration: runningLease.LeaseGeneration} + for _, phase := range []string{"ack", "start", "renew"} { + response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+runningLease.TaskID+"/"+phase, "Bearer node-secret", ack) + if response.Code != http.StatusOK { + t.Fatalf("%s status = %d: %s", phase, response.Code, response.Body.String()) + } + } + stale := ack + stale.LeaseGeneration++ + staleResponse := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+runningLease.TaskID+"/result", "Bearer node-secret", TaskResult{ + TaskID: runningLease.TaskID, AccountID: runningLease.AccountID, LeaseGeneration: stale.LeaseGeneration, Status: TaskSucceeded, CorrelationID: "stale", + }) + if staleResponse.Code != http.StatusConflict { + t.Fatalf("stale result status = %d", staleResponse.Code) + } +} diff --git a/control-plane/protocol.go b/control-plane/protocol.go new file mode 100644 index 0000000..8f17d15 --- /dev/null +++ b/control-plane/protocol.go @@ -0,0 +1,225 @@ +package controlplane + +import ( + "encoding/json" + "time" +) + +const ProtocolVersion = "v1" +const MaxTaskResultBytes = 512 * 1024 + +type TaskStatus string + +const ( + TaskPending TaskStatus = "Pending" + TaskAccepted TaskStatus = "Accepted" + TaskRunning TaskStatus = "Running" + TaskSucceeded TaskStatus = "Succeeded" + TaskFailed TaskStatus = "Failed" + TaskCancelled TaskStatus = "Cancelled" + TaskExpired TaskStatus = "Expired" + TaskResultUnconfirmed TaskStatus = "ResultUnconfirmed" +) + +type NodeStatus string + +const ( + NodeRegistered NodeStatus = "Registered" + NodeOnline NodeStatus = "Online" + NodeDegraded NodeStatus = "Degraded" + NodeOffline NodeStatus = "Offline" + NodeSessionLocked NodeStatus = "SessionLocked" + NodeWechatNotRunning NodeStatus = "WechatNotRunning" + NodeWechatNotLogged NodeStatus = "WechatNotLoggedIn" +) + +type ChatType string + +const ( + ChatGroup ChatType = "Group" + ChatPrivate ChatType = "Private" +) + +type NodeRegistration struct { + NodeID string `json:"node_id"` + AgentVersion string `json:"agent_version"` + ProtocolVersion string `json:"protocol_version"` + Capabilities []string `json:"capabilities"` + ReportingConfigVersion int64 `json:"reporting_config_version"` + Accounts []AccountSummary `json:"accounts"` +} + +type AccountSummary struct { + AccountID string `json:"account_id"` + Active bool `json:"active"` + Verified bool `json:"verified"` + AllowedGroupCount int `json:"allowed_group_count"` + AllowedPrivateCount int `json:"allowed_private_count"` +} + +type Heartbeat struct { + NodeID string `json:"node_id"` + AgentVersion string `json:"agent_version"` + ProtocolVersion string `json:"protocol_version"` + NodeStatus NodeStatus `json:"node_status"` + WechatRunning bool `json:"wechat_running"` + WechatLoggedIn bool `json:"wechat_logged_in"` + SessionLocked bool `json:"session_locked"` + ActiveAccountID string `json:"active_account_id,omitempty"` + QueueLength int `json:"queue_length"` + ReportingConfigVersion int64 `json:"reporting_config_version"` + CorrelationID string `json:"correlation_id"` + LastErrorCode string `json:"last_error_code,omitempty"` +} + +type Task struct { + TaskID string `json:"task_id"` + NodeID string `json:"node_id"` + AccountID string `json:"account_id"` + Kind string `json:"kind"` + IdempotencyKey string `json:"idempotency_key"` + Payload jsonRaw `json:"payload"` + NotAfter *time.Time `json:"not_after,omitempty"` + Status TaskStatus `json:"status"` + StateVersion int64 `json:"state_version"` + LeaseGeneration int64 `json:"lease_generation"` + LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"` + LeaseOwner string `json:"lease_owner,omitempty"` + CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` + Result *TaskResult `json:"result,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastCorrelationID string `json:"last_correlation_id,omitempty"` +} + +// jsonRaw preserves task/content JSON without coupling the API to a concrete payload type. +type jsonRaw = json.RawMessage + +type TaskSubmission struct { + NodeID string `json:"node_id"` + AccountID string `json:"account_id"` + Kind string `json:"kind"` + IdempotencyKey string `json:"idempotency_key"` + Payload jsonRaw `json:"payload"` + NotAfter *time.Time `json:"not_after,omitempty"` +} + +type ReadTaskSubmission struct { + NodeID string `json:"node_id"` + AccountID string `json:"account_id"` + IdempotencyKey string `json:"idempotency_key"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + Contains string `json:"contains,omitempty"` + GroupsOnly *bool `json:"groups_only,omitempty"` + ChatID string `json:"chat_id,omitempty"` + IncludeContent bool `json:"include_content,omitempty"` + NotAfter *time.Time `json:"not_after,omitempty"` +} + +type TaskResult struct { + TaskID string `json:"task_id"` + AccountID string `json:"account_id"` + LeaseGeneration int64 `json:"lease_generation"` + Status TaskStatus `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + Message string `json:"message,omitempty"` + HasSideEffect bool `json:"has_side_effect"` + Content jsonRaw `json:"content,omitempty"` + CorrelationID string `json:"correlation_id"` +} + +type TaskAck struct { + TaskID string `json:"task_id"` + AccountID string `json:"account_id"` + LeaseGeneration int64 `json:"lease_generation"` +} + +type MessageEvent struct { + NodeID string `json:"node_id"` + AccountID string `json:"account_id"` + ChatID string `json:"chat_id"` + ChatType ChatType `json:"chat_type"` + EventSeq int64 `json:"event_seq"` + EventType string `json:"event_type"` + OccurredAt time.Time `json:"occurred_at"` + Content string `json:"content,omitempty"` + ConfigVersion int64 `json:"config_version"` + AuthorizationVersion int64 `json:"authorization_version"` + CorrelationID string `json:"correlation_id"` + Authorized bool `json:"authorized"` +} + +type StoredEvent struct { + MessageEvent + EventID string `json:"event_id"` + ContentHash string `json:"content_hash"` + ReceivedAt time.Time `json:"received_at"` +} + +type AuditEntry struct { + ID string `json:"id"` + At time.Time `json:"at"` + Principal string `json:"principal"` + Action string `json:"action"` + Resource string `json:"resource"` + CorrelationID string `json:"correlation_id"` + Outcome string `json:"outcome"` +} + +type Node struct { + NodeID string `json:"node_id"` + AgentVersion string `json:"agent_version"` + ProtocolVersion string `json:"protocol_version"` + Capabilities []string `json:"capabilities"` + Status NodeStatus `json:"status"` + LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"` + WechatRunning bool `json:"wechat_running"` + WechatLoggedIn bool `json:"wechat_logged_in"` + SessionLocked bool `json:"session_locked"` + ActiveAccountID string `json:"active_account_id,omitempty"` + QueueLength int `json:"queue_length"` + ReportingConfigVersion int64 `json:"reporting_config_version"` + Accounts []AccountSummary `json:"accounts"` + LastErrorCode string `json:"last_error_code,omitempty"` + LastCorrelationID string `json:"last_correlation_id,omitempty"` +} + +type PersistedState struct { + Nodes map[string]Node `json:"nodes"` + Tasks map[string]Task `json:"tasks"` + Events []StoredEvent `json:"events"` + Audit []AuditEntry `json:"audit"` +} + +type NodeResponse struct { + NodeID string `json:"node_id"` + Status NodeStatus `json:"status"` + Authenticated bool `json:"authenticated,omitempty"` + LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"` + CorrelationID string `json:"correlation_id"` +} + +type TaskBatch struct { + Tasks []Task `json:"tasks"` +} + +type EventReceipt struct { + Accepted bool `json:"accepted"` + Duplicate bool `json:"duplicate"` + EventID string `json:"event_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type TaskSubmissionResponse struct { + TaskID string `json:"task_id"` + Status TaskStatus `json:"status"` + Duplicate bool `json:"duplicate"` + StateVersion int64 `json:"state_version"` +} + +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + CorrelationID string `json:"correlation_id"` +} diff --git a/control-plane/server.go b/control-plane/server.go new file mode 100644 index 0000000..abcf2da --- /dev/null +++ b/control-plane/server.go @@ -0,0 +1,1321 @@ +package controlplane + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "mime" + "net/http" + "net/url" + "path" + "sort" + "strconv" + "strings" + "sync" + "time" + + webassets "git.ipao.vip/rogee/wx-win-agent/control-plane/web" +) + +type ServerConfig struct { + ListenAddr string + DataFile string + NodeTokens map[string]string + WebUsers map[string]string + LeaseTTL time.Duration + HeartbeatTimeout time.Duration + SessionTTL time.Duration +} + +func DefaultServerConfig() ServerConfig { + return ServerConfig{ + ListenAddr: "127.0.0.1:8090", + DataFile: "control-plane-data.json", + NodeTokens: map[string]string{}, + WebUsers: map[string]string{}, + LeaseTTL: 30 * time.Second, + HeartbeatTimeout: 45 * time.Second, + SessionTTL: 8 * time.Hour, + } +} + +type Server struct { + config ServerConfig + store *Store + nodeTokenHashes map[string][32]byte + userPasswords map[string][32]byte + sessionMu sync.Mutex + sessions map[string]session +} + +type session struct { + Username string + Expires time.Time +} + +type requestError struct { + status int + code string + message string +} + +func (e requestError) Error() string { return e.message } + +func NewServer(config ServerConfig) (*Server, error) { + defaults := DefaultServerConfig() + if config.ListenAddr == "" { + config.ListenAddr = defaults.ListenAddr + } + if config.DataFile == "" { + config.DataFile = defaults.DataFile + } + if config.LeaseTTL <= 0 { + config.LeaseTTL = defaults.LeaseTTL + } + if config.HeartbeatTimeout <= 0 { + config.HeartbeatTimeout = defaults.HeartbeatTimeout + } + if config.SessionTTL <= 0 { + config.SessionTTL = defaults.SessionTTL + } + if config.NodeTokens == nil { + config.NodeTokens = map[string]string{} + } + if config.WebUsers == nil { + config.WebUsers = map[string]string{} + } + store, err := OpenStore(config.DataFile) + if err != nil { + return nil, err + } + s := &Server{ + config: config, + store: store, + nodeTokenHashes: map[string][32]byte{}, + userPasswords: map[string][32]byte{}, + sessions: map[string]session{}, + } + for nodeID, token := range config.NodeTokens { + if validIdentifier(nodeID, 200) && token != "" { + s.nodeTokenHashes[nodeID] = sha256.Sum256([]byte(token)) + } + } + for username, password := range config.WebUsers { + if username != "" && password != "" { + s.userPasswords[username] = sha256.Sum256([]byte(password)) + } + } + return s, nil +} + +func (s *Server) Handler() http.Handler { return s } + +func (s *Server) ListenAndServe(ctx context.Context) error { + server := &http.Server{Addr: s.config.ListenAddr, Handler: s, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + err := server.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + correlationID := r.Header.Get("X-Correlation-Id") + if !validIdentifier(correlationID, 128) { + correlationID = randomID() + } + w.Header().Set("X-Correlation-Id", correlationID) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + var err error + switch { + case r.URL.Path == "/" && r.Method == http.MethodGet: + s.serveFrontend(w, "dist/index.html") + return + case strings.HasPrefix(r.URL.Path, "/assets/") && r.Method == http.MethodGet: + s.serveFrontend(w, path.Join("dist", strings.TrimPrefix(r.URL.Path, "/"))) + return + case r.URL.Path == "/healthz" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "protocol_version": ProtocolVersion, "correlation_id": correlationID}) + return + case r.URL.Path == "/v1/auth/login" && r.Method == http.MethodPost: + err = s.login(w, r, correlationID) + case r.URL.Path == "/v1/nodes" && r.Method == http.MethodGet: + err = s.listNodes(w, r) + case r.URL.Path == "/v1/nodes/register" && r.Method == http.MethodPost: + err = s.registerNode(w, r, correlationID) + case strings.HasPrefix(r.URL.Path, "/v1/nodes/"): + err = s.nodeRoute(w, r, correlationID) + case strings.HasPrefix(r.URL.Path, "/v1/reads/"): + err = s.readRoute(w, r, correlationID) + case r.URL.Path == "/v1/tasks" || strings.HasPrefix(r.URL.Path, "/v1/tasks/"): + err = s.taskRoute(w, r, correlationID) + case r.URL.Path == "/v1/events": + err = s.eventRoute(w, r, correlationID) + case r.URL.Path == "/v1/audit" && r.Method == http.MethodGet: + err = s.auditRoute(w, r, correlationID) + default: + err = requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } + if err != nil { + writeError(w, err, correlationID) + } +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request, correlationID string) error { + var request struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(r, &request, 8*1024); err != nil { + return err + } + password, exists := s.userPasswords[request.Username] + provided := sha256.Sum256([]byte(request.Password)) + if !exists || subtle.ConstantTimeCompare(password[:], provided[:]) != 1 { + return requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Authentication failed."} + } + token := randomID() + s.sessionMu.Lock() + s.sessions[token] = session{Username: request.Username, Expires: time.Now().UTC().Add(s.config.SessionTTL)} + s.sessionMu.Unlock() + _ = s.appendAudit(request.Username, "login", "session", correlationID, "success") + writeJSON(w, http.StatusOK, map[string]any{"access_token": token, "token_type": "Bearer", "expires_in": int(s.config.SessionTTL.Seconds()), "correlation_id": correlationID}) + return nil +} + +func (s *Server) registerNode(w http.ResponseWriter, r *http.Request, correlationID string) error { + nodeID, err := s.authenticateNode(r) + if err != nil { + return err + } + var request NodeRegistration + if err := decodeJSON(r, &request, 128*1024); err != nil { + return err + } + if request.NodeID != nodeID { + return requestError{status: http.StatusForbidden, code: "NodeIdentityMismatch", message: "The token is not assigned to this node."} + } + if !validIdentifier(request.AgentVersion, 80) || request.ProtocolVersion != ProtocolVersion || len(request.Capabilities) > 100 || !validCapabilities(request.Capabilities) || !validAccountSummaries(request.Accounts) { + return requestError{status: http.StatusBadRequest, code: "InvalidRegistration", message: "Node registration is invalid."} + } + now := time.Now().UTC() + if err := s.store.Mutate(func(state *PersistedState) error { + node := state.Nodes[nodeID] + node.NodeID = nodeID + node.AgentVersion = request.AgentVersion + node.ProtocolVersion = request.ProtocolVersion + node.Capabilities = append([]string(nil), request.Capabilities...) + node.Status = NodeOnline + node.LastHeartbeatAt = &now + node.ReportingConfigVersion = request.ReportingConfigVersion + node.Accounts = append([]AccountSummary(nil), request.Accounts...) + node.LastCorrelationID = correlationID + state.Nodes[nodeID] = node + state.Audit = appendAudit(state.Audit, "node:"+nodeID, "node.register", nodeID, correlationID, "success", now) + return nil + }); err != nil { + return requestError{status: http.StatusInternalServerError, code: "PersistenceFailed", message: "The control plane could not persist the node registration."} + } + writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, Status: NodeOnline, Authenticated: true, CorrelationID: correlationID}) + return nil +} + +func (s *Server) nodeRoute(w http.ResponseWriter, r *http.Request, correlationID string) error { + parts := pathParts(r.URL.Path) + if len(parts) < 3 || parts[0] != "v1" || parts[1] != "nodes" { + return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } + nodeID, err := s.authenticateNode(r) + if err != nil { + return err + } + if nodeID != parts[2] { + return requestError{status: http.StatusForbidden, code: "NodeIdentityMismatch", message: "The token is not assigned to this node."} + } + if len(parts) == 3 && r.Method == http.MethodGet { + return s.nodeDetails(w, nodeID, correlationID) + } + if len(parts) < 4 { + return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } + switch parts[3] { + case "heartbeat": + if r.Method != http.MethodPost || len(parts) != 4 { + return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."} + } + return s.heartbeat(w, r, nodeID, correlationID) + case "tasks": + return s.nodeTaskRoute(w, r, nodeID, parts[4:], correlationID) + case "events": + if r.Method != http.MethodPost || len(parts) != 4 { + return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."} + } + return s.ingestEvent(w, r, nodeID, correlationID) + default: + return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } +} + +func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) error { + if _, err := s.authenticateWeb(r); err != nil { + return err + } + var nodes []Node + if err := s.store.Mutate(func(state *PersistedState) error { + now := time.Now().UTC() + for nodeID, value := range state.Nodes { + node := value + nodeChanged := false + if node.LastHeartbeatAt == nil || now.Sub(*node.LastHeartbeatAt) > s.config.HeartbeatTimeout { + if node.Status != NodeOffline { + node.Status = NodeOffline + nodeChanged = true + } + } + if nodeChanged { + state.Nodes[nodeID] = node + } + nodes = append(nodes, node) + } + return nil + }); err != nil { + return err + } + sort.Slice(nodes, func(i, j int) bool { return nodes[i].NodeID < nodes[j].NodeID }) + writeJSON(w, http.StatusOK, map[string]any{"nodes": nodes}) + return nil +} + +func (s *Server) nodeDetails(w http.ResponseWriter, nodeID, _ string) error { + var node Node + if err := s.store.Read(func(state PersistedState) error { + value, ok := state.Nodes[nodeID] + if !ok { + return requestError{status: http.StatusNotFound, code: "NodeNotFound", message: "Node was not registered."} + } + node = value + return nil + }); err != nil { + return err + } + writeJSON(w, http.StatusOK, node) + return nil +} + +func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request, nodeID, correlationID string) error { + var request Heartbeat + if err := decodeJSON(r, &request, 32*1024); err != nil { + return err + } + if request.NodeID != nodeID || request.ProtocolVersion != ProtocolVersion || !validIdentifier(request.AgentVersion, 80) || !validNodeStatus(request.NodeStatus) || request.QueueLength < 0 || request.QueueLength > 100000 { + return requestError{status: http.StatusBadRequest, code: "InvalidHeartbeat", message: "Heartbeat is invalid."} + } + now := time.Now().UTC() + if err := s.store.Mutate(func(state *PersistedState) error { + node := state.Nodes[nodeID] + if node.NodeID == "" { + return requestError{status: http.StatusConflict, code: "NodeNotRegistered", message: "Register the node before sending a heartbeat."} + } + node.AgentVersion = request.AgentVersion + node.ProtocolVersion = request.ProtocolVersion + node.Status = request.NodeStatus + node.WechatRunning = request.WechatRunning + node.WechatLoggedIn = request.WechatLoggedIn + node.SessionLocked = request.SessionLocked + node.ActiveAccountID = request.ActiveAccountID + node.QueueLength = request.QueueLength + node.ReportingConfigVersion = request.ReportingConfigVersion + node.LastErrorCode = request.LastErrorCode + node.LastHeartbeatAt = &now + node.LastCorrelationID = correlationID + state.Nodes[nodeID] = node + state.Audit = appendAudit(state.Audit, "node:"+nodeID, "node.heartbeat", nodeID, correlationID, "success", now) + return nil + }); err != nil { + return err + } + writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, Status: request.NodeStatus, LastHeartbeatAt: &now, CorrelationID: correlationID}) + return nil +} + +func (s *Server) nodeTaskRoute(w http.ResponseWriter, r *http.Request, nodeID string, parts []string, correlationID string) error { + if len(parts) == 0 && r.Method == http.MethodGet { + return s.pollTasks(w, r, nodeID, correlationID) + } + if len(parts) != 2 || r.Method != http.MethodPost { + return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } + taskID := parts[0] + switch parts[1] { + case "ack": + return s.ackTask(w, r, nodeID, taskID, correlationID) + case "start": + return s.startTask(w, r, nodeID, taskID, correlationID) + case "renew": + return s.renewTask(w, r, nodeID, taskID, correlationID) + case "result": + return s.recordTaskResult(w, r, nodeID, taskID, correlationID) + default: + return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } +} + +func (s *Server) pollTasks(w http.ResponseWriter, r *http.Request, nodeID, correlationID string) error { + waitSeconds := queryWaitSeconds(r.URL.Query().Get("wait_seconds")) + deadline := time.Now().Add(time.Duration(waitSeconds) * time.Second) + for { + tasks, err := s.claimTasks(r, nodeID, correlationID) + if err != nil { + return err + } + if len(tasks) > 0 || waitSeconds == 0 || !time.Now().Before(deadline) { + writeJSON(w, http.StatusOK, TaskBatch{Tasks: tasks}) + return nil + } + select { + case <-r.Context().Done(): + return nil + case <-time.After(250 * time.Millisecond): + } + } +} + +func (s *Server) claimTasks(r *http.Request, nodeID, correlationID string) ([]Task, error) { + accountID := r.URL.Query().Get("account_id") + if accountID != "" && !validIdentifier(accountID, 200) { + return nil, requestError{status: http.StatusBadRequest, code: "InvalidAccount", message: "account_id is invalid."} + } + now := time.Now().UTC() + var tasks []Task + if err := s.store.Mutate(func(state *PersistedState) error { + for taskID, value := range state.Tasks { + task := value + if task.NodeID != nodeID || (accountID != "" && task.AccountID != accountID) || terminal(task.Status) { + continue + } + changed := false + if task.NotAfter != nil && !now.Before(*task.NotAfter) { + if task.Status == TaskPending { + task.Status = TaskExpired + task.StateVersion++ + task.UpdatedAt = now + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + changed = true + } else if task.Status == TaskAccepted || task.Status == TaskRunning { + markUnconfirmed(&task, "TaskDeadlineReached", now) + changed = true + } + } + if terminal(task.Status) { + if changed { + state.Tasks[taskID] = task + } + continue + } + if task.CancelRequestedAt != nil && task.Status == TaskPending && !leaseActive(task, now) { + task.Status = TaskCancelled + task.StateVersion++ + task.UpdatedAt = now + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + changed = true + } + if terminal(task.Status) { + if changed { + state.Tasks[taskID] = task + } + continue + } + if task.Status == TaskPending { + if leaseActive(task, now) && task.LeaseOwner != nodeID { + continue + } + if !leaseActive(task, now) { + task.LeaseGeneration++ + expires := now.Add(s.config.LeaseTTL) + task.LeaseExpiresAt = &expires + task.LeaseOwner = nodeID + task.UpdatedAt = now + task.LastCorrelationID = correlationID + changed = true + } + } + if task.Status == TaskAccepted || task.Status == TaskRunning { + if task.LeaseOwner != nodeID || !leaseActive(task, now) { + if task.LeaseOwner == nodeID && !leaseActive(task, now) { + markUnconfirmed(&task, "LeaseExpired", now) + changed = true + } + if changed { + state.Tasks[taskID] = task + } + continue + } + } + if changed { + state.Tasks[taskID] = task + } + if task.Status == TaskPending || task.Status == TaskAccepted || task.Status == TaskRunning { + tasks = append(tasks, task) + } + } + state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.poll", nodeID, correlationID, "success", now) + return nil + }); err != nil { + return nil, err + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i].CreatedAt.Before(tasks[j].CreatedAt) }) + return tasks, nil +} + +func (s *Server) ackTask(w http.ResponseWriter, r *http.Request, nodeID, taskID, correlationID string) error { + var request TaskAck + if err := decodeJSON(r, &request, 8*1024); err != nil { + return err + } + if request.TaskID != taskID { + return requestError{status: http.StatusBadRequest, code: "TaskIdentityMismatch", message: "Task identity does not match the path."} + } + var task Task + err := s.store.Mutate(func(state *PersistedState) error { + value, ok := state.Tasks[taskID] + if !ok || value.NodeID != nodeID { + return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."} + } + task = value + if task.AccountID != request.AccountID { + return requestError{status: http.StatusForbidden, code: "AccountMismatch", message: "Task account does not match."} + } + if terminal(task.Status) { + return nil + } + if task.LeaseGeneration != request.LeaseGeneration || task.LeaseOwner != nodeID || !leaseActive(task, time.Now().UTC()) { + return requestError{status: http.StatusConflict, code: "LeaseMismatch", message: "The task lease is no longer valid."} + } + if task.Status != TaskPending { + if task.Status == TaskAccepted { + return nil + } + return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "The task cannot be accepted in its current state."} + } + if task.CancelRequestedAt != nil { + task.Status = TaskCancelled + task.StateVersion++ + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + } else { + task.Status = TaskAccepted + task.StateVersion++ + } + task.UpdatedAt = time.Now().UTC() + task.LastCorrelationID = correlationID + state.Tasks[taskID] = task + state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.accept", taskID, correlationID, "success", task.UpdatedAt) + return nil + }) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, task) + return nil +} + +func (s *Server) startTask(w http.ResponseWriter, r *http.Request, nodeID, taskID, correlationID string) error { + var request TaskAck + if err := decodeJSON(r, &request, 8*1024); err != nil { + return err + } + if request.TaskID != taskID { + return requestError{status: http.StatusBadRequest, code: "TaskIdentityMismatch", message: "Task identity does not match the path."} + } + var task Task + err := s.store.Mutate(func(state *PersistedState) error { + value, ok := state.Tasks[taskID] + if !ok || value.NodeID != nodeID { + return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."} + } + task = value + if task.AccountID != request.AccountID || task.LeaseGeneration != request.LeaseGeneration || task.LeaseOwner != nodeID { + return requestError{status: http.StatusConflict, code: "LeaseMismatch", message: "The task lease is no longer valid."} + } + now := time.Now().UTC() + if terminal(task.Status) { + return nil + } + if task.Status != TaskAccepted { + return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "The task must be accepted before execution."} + } + if task.CancelRequestedAt != nil { + task.Status = TaskCancelled + task.StateVersion++ + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + } else if task.NotAfter != nil && !now.Before(*task.NotAfter) { + task.Status = TaskExpired + task.StateVersion++ + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + } else if !leaseActive(task, now) { + return requestError{status: http.StatusConflict, code: "LeaseExpired", message: "The task lease expired before execution started."} + } else { + task.Status = TaskRunning + task.StateVersion++ + } + task.UpdatedAt = now + task.LastCorrelationID = correlationID + state.Tasks[taskID] = task + state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.start", taskID, correlationID, "success", now) + return nil + }) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, task) + return nil +} + +func (s *Server) renewTask(w http.ResponseWriter, r *http.Request, nodeID, taskID, correlationID string) error { + var request TaskAck + if err := decodeJSON(r, &request, 8*1024); err != nil { + return err + } + if request.TaskID != taskID { + return requestError{status: http.StatusBadRequest, code: "TaskIdentityMismatch", message: "Task identity does not match the path."} + } + var task Task + if err := s.store.Mutate(func(state *PersistedState) error { + value, ok := state.Tasks[taskID] + if !ok || value.NodeID != nodeID { + return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."} + } + task = value + if task.AccountID != request.AccountID || task.LeaseGeneration != request.LeaseGeneration || task.LeaseOwner != nodeID { + return requestError{status: http.StatusConflict, code: "LeaseMismatch", message: "The task lease is no longer valid."} + } + if task.CancelRequestedAt != nil { + return requestError{status: http.StatusConflict, code: "CancelRequested", message: "The task lease cannot be renewed after cancellation was requested."} + } + now := time.Now().UTC() + if task.Status != TaskAccepted && task.Status != TaskRunning || !leaseActive(task, now) { + return requestError{status: http.StatusConflict, code: "LeaseExpired", message: "The task lease is no longer renewable."} + } + expires := now.Add(s.config.LeaseTTL) + task.LeaseExpiresAt = &expires + task.StateVersion++ + task.UpdatedAt = now + task.LastCorrelationID = correlationID + state.Tasks[taskID] = task + state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.renew", taskID, correlationID, "success", now) + return nil + }); err != nil { + return err + } + writeJSON(w, http.StatusOK, task) + return nil +} + +func (s *Server) recordTaskResult(w http.ResponseWriter, r *http.Request, nodeID, taskID, correlationID string) error { + var result TaskResult + if err := decodeJSON(r, &result, MaxTaskResultBytes+32*1024); err != nil { + return err + } + if result.TaskID != taskID || !terminal(result.Status) { + return requestError{status: http.StatusBadRequest, code: "InvalidTaskResult", message: "Task result or terminal status is invalid."} + } + if len(result.Content) > MaxTaskResultBytes { + return requestError{status: http.StatusRequestEntityTooLarge, code: "ContentTooLarge", message: "The task result content is too large."} + } + var task Task + err := s.store.Mutate(func(state *PersistedState) error { + value, ok := state.Tasks[taskID] + if !ok || value.NodeID != nodeID { + return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."} + } + task = value + if task.AccountID != result.AccountID { + return requestError{status: http.StatusForbidden, code: "AccountMismatch", message: "Task account does not match."} + } + if hasTaskContent(result.Content) && !isReadTaskKind(task.Kind) { + return requestError{status: http.StatusForbidden, code: "ContentNotAllowed", message: "Only read task results may contain content."} + } + if terminal(task.Status) { + if task.Result != nil && task.Result.Status == result.Status && task.Result.ErrorCode == result.ErrorCode { + return nil + } + return requestError{status: http.StatusConflict, code: "TerminalState", message: "The task already has a different terminal result."} + } + if task.LeaseGeneration != result.LeaseGeneration || task.LeaseOwner != nodeID { + return requestError{status: http.StatusConflict, code: "LeaseMismatch", message: "The result belongs to an expired task lease."} + } + if task.Status != TaskAccepted && task.Status != TaskRunning { + return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "The task is not awaiting a final result."} + } + result.CorrelationID = firstNonEmpty(result.CorrelationID, correlationID) + task.Result = &result + task.Status = result.Status + task.StateVersion++ + task.UpdatedAt = time.Now().UTC() + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + task.LastCorrelationID = correlationID + state.Tasks[taskID] = task + state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.result", taskID, correlationID, "success", task.UpdatedAt) + return nil + }) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, task) + return nil +} + +func (s *Server) readRoute(w http.ResponseWriter, r *http.Request, correlationID string) error { + username, err := s.authenticateWeb(r) + if err != nil { + return err + } + parts := pathParts(r.URL.Path) + if len(parts) != 3 || parts[0] != "v1" || parts[1] != "reads" || r.Method != http.MethodPost { + return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } + kind := "read-" + parts[2] + if !isReadTaskKind(kind) { + return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } + var request ReadTaskSubmission + if err := decodeJSON(r, &request, 64*1024); err != nil { + return err + } + payload, err := readTaskPayload(kind, request) + if err != nil { + return err + } + return s.createTaskSubmission(w, TaskSubmission{ + NodeID: request.NodeID, AccountID: request.AccountID, Kind: kind, + IdempotencyKey: request.IdempotencyKey, Payload: payload, NotAfter: request.NotAfter, + }, username, correlationID) +} + +type readSessionsPayload struct { + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type readContactsPayload struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Contains string `json:"contains,omitempty"` + GroupsOnly bool `json:"groups_only"` +} + +type readMessagesPayload struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + ChatID string `json:"chat_id"` + IncludeContent bool `json:"include_content"` +} + +func isReadTaskKind(kind string) bool { + switch kind { + case "read-sessions", "read-contacts", "read-messages": + return true + default: + return false + } +} + +func readTaskPayload(kind string, request ReadTaskSubmission) (jsonRaw, error) { + limit := request.Limit + if limit == 0 { + limit = 50 + } + if limit < 1 || limit > 200 || request.Offset < 0 { + return nil, requestError{status: http.StatusBadRequest, code: "InvalidPagination", message: "limit must be 1..200 and offset must be non-negative."} + } + if request.Contains != "" && !validIdentifier(request.Contains, 200) { + return nil, requestError{status: http.StatusBadRequest, code: "InvalidReadRequest", message: "contains is invalid."} + } + switch kind { + case "read-sessions": + if request.Contains != "" || request.GroupsOnly != nil || request.ChatID != "" || request.IncludeContent { + return nil, requestError{status: http.StatusBadRequest, code: "InvalidReadRequest", message: "The sessions read request contains unsupported fields."} + } + return json.Marshal(readSessionsPayload{Limit: limit, Offset: request.Offset}) + case "read-contacts": + if request.GroupsOnly == nil || request.ChatID != "" || request.IncludeContent { + return nil, requestError{status: http.StatusBadRequest, code: "InvalidReadRequest", message: "contacts requires groups_only and does not accept chat_id or include_content."} + } + return json.Marshal(readContactsPayload{Limit: limit, Offset: request.Offset, Contains: request.Contains, GroupsOnly: *request.GroupsOnly}) + case "read-messages": + if !validIdentifier(request.ChatID, 512) || request.GroupsOnly != nil || request.Contains != "" { + return nil, requestError{status: http.StatusBadRequest, code: "InvalidReadRequest", message: "messages requires a stable chat_id and does not accept contains or groups_only."} + } + return json.Marshal(readMessagesPayload{Limit: limit, Offset: request.Offset, ChatID: request.ChatID, IncludeContent: request.IncludeContent}) + default: + return nil, requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} + } +} + +func validTaskPayload(kind string, payload jsonRaw) bool { + if kind == "send-text" { + return validSendTextPayload(payload) + } + if !isReadTaskKind(kind) || len(payload) == 0 || len(payload) > 64*1024 || !json.Valid(payload) { + return false + } + switch kind { + case "read-sessions": + var value readSessionsPayload + return decodeRaw(payload, &value) && validPagination(value.Limit, value.Offset) + case "read-contacts": + var value readContactsPayload + return decodeRaw(payload, &value) && validPagination(value.Limit, value.Offset) && (value.Contains == "" || validIdentifier(value.Contains, 200)) + case "read-messages": + var value readMessagesPayload + return decodeRaw(payload, &value) && validPagination(value.Limit, value.Offset) && validIdentifier(value.ChatID, 512) + default: + return false + } +} + +func decodeRaw(payload jsonRaw, target any) bool { + decoder := json.NewDecoder(strings.NewReader(string(payload))) + decoder.DisallowUnknownFields() + if decoder.Decode(target) != nil { + return false + } + var extra any + return decoder.Decode(&extra) == io.EOF +} + +func validPagination(limit, offset int) bool { return limit >= 1 && limit <= 200 && offset >= 0 } + +func hasTaskContent(content jsonRaw) bool { return len(content) != 0 && string(content) != "null" } + +func (s *Server) taskRoute(w http.ResponseWriter, r *http.Request, correlationID string) error { + username, err := s.authenticateWeb(r) + if err != nil { + return err + } + parts := pathParts(r.URL.Path) + if len(parts) == 2 && parts[0] == "v1" && parts[1] == "tasks" { + switch r.Method { + case http.MethodGet: + return s.listTasks(w, r) + case http.MethodPost: + return s.createTask(w, r, username, correlationID) + default: + return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."} + } + } + if len(parts) == 3 && r.Method == http.MethodGet { + return s.getTask(w, parts[2]) + } + if len(parts) == 4 && parts[3] == "cancel" && r.Method == http.MethodPost { + return s.cancelTask(w, parts[2], username, correlationID) + } + return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."} +} + +func (s *Server) createTask(w http.ResponseWriter, r *http.Request, username, correlationID string) error { + var request TaskSubmission + if err := decodeJSON(r, &request, 128*1024); err != nil { + return err + } + return s.createTaskSubmission(w, request, username, correlationID) +} + +func (s *Server) createTaskSubmission(w http.ResponseWriter, request TaskSubmission, username, correlationID string) error { + if !validIdentifier(request.NodeID, 200) || !validIdentifier(request.AccountID, 200) || !validIdentifier(request.Kind, 80) || !validIdentifier(request.IdempotencyKey, 128) { + return requestError{status: http.StatusBadRequest, code: "InvalidTask", message: "Task identity fields are invalid."} + } + if !validTaskPayload(request.Kind, request.Payload) { + return requestError{status: http.StatusBadRequest, code: "UnsupportedTask", message: "The task kind or payload is not supported."} + } + if request.NotAfter != nil && !request.NotAfter.After(time.Now().UTC()) { + return requestError{status: http.StatusBadRequest, code: "InvalidDeadline", message: "not_after must be in the future."} + } + var response TaskSubmissionResponse + err := s.store.Mutate(func(state *PersistedState) error { + node, registered := state.Nodes[request.NodeID] + if !registered { + return requestError{status: http.StatusConflict, code: "NodeNotReady", message: "The target node is not registered."} + } + if !nodeHasAccount(node, request.AccountID) { + return requestError{status: http.StatusConflict, code: "AccountNotReady", message: "The target account is not currently registered on the node."} + } + payloadKey := string(request.Payload) + for _, existing := range state.Tasks { + if existing.NodeID != request.NodeID || existing.AccountID != request.AccountID || existing.IdempotencyKey != request.IdempotencyKey { + continue + } + if existing.Kind != request.Kind || string(existing.Payload) != payloadKey { + return requestError{status: http.StatusConflict, code: "IdempotencyConflict", message: "The idempotency key is already bound to different task parameters."} + } + response = TaskSubmissionResponse{TaskID: existing.TaskID, Status: existing.Status, Duplicate: true, StateVersion: existing.StateVersion} + return nil + } + now := time.Now().UTC() + task := Task{ + TaskID: randomID(), NodeID: request.NodeID, AccountID: request.AccountID, Kind: request.Kind, + IdempotencyKey: request.IdempotencyKey, Payload: append(jsonRaw(nil), request.Payload...), NotAfter: request.NotAfter, + Status: TaskPending, StateVersion: 1, CreatedAt: now, UpdatedAt: now, LastCorrelationID: correlationID, + } + state.Tasks[task.TaskID] = task + state.Audit = appendAudit(state.Audit, "user:"+username, "task.create", task.TaskID, correlationID, "success", now) + response = TaskSubmissionResponse{TaskID: task.TaskID, Status: task.Status, Duplicate: false, StateVersion: task.StateVersion} + return nil + }) + if err != nil { + return err + } + writeJSON(w, http.StatusAccepted, response) + return nil +} + +func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) error { + nodeID := r.URL.Query().Get("node_id") + accountID := r.URL.Query().Get("account_id") + limit := queryLimit(r.URL.Query().Get("limit")) + var tasks []Task + if err := s.store.Read(func(state PersistedState) error { + for _, task := range state.Tasks { + if nodeID != "" && task.NodeID != nodeID || accountID != "" && task.AccountID != accountID { + continue + } + tasks = append(tasks, task) + } + return nil + }); err != nil { + return err + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i].CreatedAt.After(tasks[j].CreatedAt) }) + if len(tasks) > limit { + tasks = tasks[:limit] + } + writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks}) + return nil +} + +func (s *Server) getTask(w http.ResponseWriter, taskID string) error { + if !validIdentifier(taskID, 200) { + return requestError{status: http.StatusBadRequest, code: "InvalidTask", message: "Task ID is invalid."} + } + var task Task + if err := s.store.Read(func(state PersistedState) error { + value, ok := state.Tasks[taskID] + if !ok { + return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."} + } + task = value + return nil + }); err != nil { + return err + } + writeJSON(w, http.StatusOK, task) + return nil +} + +func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlationID string) error { + var task Task + err := s.store.Mutate(func(state *PersistedState) error { + value, ok := state.Tasks[taskID] + if !ok { + return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."} + } + task = value + if terminal(task.Status) { + return nil + } + now := time.Now().UTC() + if task.CancelRequestedAt == nil { + task.CancelRequestedAt = &now + task.StateVersion++ + if task.Status == TaskPending && !leaseActive(task, now) { + task.Status = TaskCancelled + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + } + task.UpdatedAt = now + state.Audit = appendAudit(state.Audit, "user:"+username, "task.cancel-request", taskID, correlationID, "success", now) + state.Tasks[taskID] = task + } + return nil + }) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, task) + return nil +} + +func (s *Server) eventRoute(w http.ResponseWriter, r *http.Request, _ string) error { + if r.Method != http.MethodGet { + return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."} + } + if _, err := s.authenticateWeb(r); err != nil { + return err + } + return s.listEvents(w, r) +} + +func (s *Server) ingestEvent(w http.ResponseWriter, r *http.Request, nodeID, correlationID string) error { + var event MessageEvent + if err := decodeJSON(r, &event, 64*1024); err != nil { + return err + } + if event.NodeID != nodeID || !validIdentifier(event.AccountID, 200) || !validIdentifier(event.ChatID, 512) || event.EventSeq <= 0 || !validIdentifier(event.EventType, 80) || event.OccurredAt.IsZero() || event.ConfigVersion <= 0 || event.AuthorizationVersion <= 0 { + return requestError{status: http.StatusBadRequest, code: "InvalidEvent", message: "Event identity or authorization metadata is invalid."} + } + if event.ChatType != ChatGroup && event.ChatType != ChatPrivate { + return requestError{status: http.StatusBadRequest, code: "InvalidChatType", message: "Only group and private chat events are supported."} + } + if event.EventType != "message" { + return requestError{status: http.StatusBadRequest, code: "UnsupportedEvent", message: "Only message events are supported in v1."} + } + if len(event.Content) > 16*1024 { + return requestError{status: http.StatusBadRequest, code: "EventTooLarge", message: "Event content is too large."} + } + if !event.Authorized { + return requestError{status: http.StatusForbidden, code: "ReportingNotAuthorized", message: "The node did not authorize this event."} + } + contentHash := eventHash(event) + var receipt EventReceipt + err := s.store.Mutate(func(state *PersistedState) error { + for _, existing := range state.Events { + if existing.NodeID != event.NodeID || existing.AccountID != event.AccountID || existing.ChatID != event.ChatID || existing.EventSeq != event.EventSeq { + continue + } + if existing.ContentHash != contentHash { + return requestError{status: http.StatusConflict, code: "EventIdempotencyConflict", message: "The event sequence is already bound to different content."} + } + receipt = EventReceipt{Accepted: true, Duplicate: true, EventID: existing.EventID} + return nil + } + now := time.Now().UTC() + stored := StoredEvent{MessageEvent: event, EventID: randomID(), ContentHash: contentHash, ReceivedAt: now} + state.Events = append(state.Events, stored) + if len(state.Events) > 10000 { + state.Events = state.Events[len(state.Events)-10000:] + } + state.Audit = appendAudit(state.Audit, "node:"+nodeID, "event.accept", event.AccountID+":"+event.ChatID, correlationID, "success", now) + receipt = EventReceipt{Accepted: true, EventID: stored.EventID} + return nil + }) + if err != nil { + return err + } + writeJSON(w, http.StatusAccepted, receipt) + return nil +} + +func (s *Server) listEvents(w http.ResponseWriter, r *http.Request) error { + query := r.URL.Query() + nodeID, accountID, chatID := query.Get("node_id"), query.Get("account_id"), query.Get("chat_id") + limit := queryLimit(query.Get("limit")) + var events []StoredEvent + if err := s.store.Read(func(state PersistedState) error { + for index := len(state.Events) - 1; index >= 0 && len(events) < limit; index-- { + event := state.Events[index] + if nodeID != "" && event.NodeID != nodeID || accountID != "" && event.AccountID != accountID || chatID != "" && event.ChatID != chatID { + continue + } + events = append(events, event) + } + return nil + }); err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]any{"events": events}) + return nil +} + +func (s *Server) auditRoute(w http.ResponseWriter, r *http.Request, _ string) error { + if _, err := s.authenticateWeb(r); err != nil { + return err + } + limit := queryLimit(r.URL.Query().Get("limit")) + var audit []AuditEntry + if err := s.store.Read(func(state PersistedState) error { + for index := len(state.Audit) - 1; index >= 0 && len(audit) < limit; index-- { + audit = append(audit, state.Audit[index]) + } + return nil + }); err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]any{"audit": audit}) + return nil +} + +func (s *Server) authenticateNode(r *http.Request) (string, error) { + token := bearerToken(r) + if token == "" { + return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Node authentication is required."} + } + provided := sha256.Sum256([]byte(token)) + for nodeID, expected := range s.nodeTokenHashes { + if subtle.ConstantTimeCompare(provided[:], expected[:]) == 1 { + return nodeID, nil + } + } + return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Node authentication failed."} +} + +func (s *Server) authenticateWeb(r *http.Request) (string, error) { + token := bearerToken(r) + if token == "" { + return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web authentication is required."} + } + now := time.Now().UTC() + s.sessionMu.Lock() + value, ok := s.sessions[token] + if ok && !now.Before(value.Expires) { + delete(s.sessions, token) + ok = false + } + s.sessionMu.Unlock() + if !ok { + return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web session is missing or expired."} + } + return value.Username, nil +} + +func (s *Server) appendAudit(principal, action, resource, correlationID, outcome string) error { + return s.store.Mutate(func(state *PersistedState) error { + state.Audit = appendAudit(state.Audit, principal, action, resource, correlationID, outcome, time.Now().UTC()) + return nil + }) +} + +func (s *Server) serveFrontend(w http.ResponseWriter, name string) { + if name == "" || name == "." || strings.Contains(name, "..") { + writeError(w, requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}, randomID()) + return + } + content, err := fs.ReadFile(webassets.Dist, name) + if err != nil { + writeError(w, requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}, randomID()) + return + } + contentType := mime.TypeByExtension(path.Ext(name)) + if contentType == "" { + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType+"; charset=utf-8") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) +} + +func decodeJSON(r *http.Request, target any, maxBytes int64) error { + defer r.Body.Close() + decoder := json.NewDecoder(io.LimitReader(r.Body, maxBytes)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return requestError{status: http.StatusBadRequest, code: "InvalidRequest", message: "Request JSON is invalid."} + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return requestError{status: http.StatusBadRequest, code: "InvalidRequest", message: "Request must contain one JSON value."} + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, err error, correlationID string) { + requestErr, ok := err.(requestError) + if !ok { + requestErr = requestError{status: http.StatusInternalServerError, code: "InternalError", message: "Request failed; use the correlation ID for diagnosis."} + } + writeJSON(w, requestErr.status, map[string]any{ + "error": map[string]string{"code": requestErr.code, "message": requestErr.message}, + "correlation_id": correlationID, + }) +} + +func pathParts(path string) []string { + trimmed := strings.Trim(path, "/") + if trimmed == "" { + return nil + } + parts := strings.Split(trimmed, "/") + for index, part := range parts { + decoded, err := url.PathUnescape(part) + if err == nil { + parts[index] = decoded + } + } + return parts +} + +func bearerToken(r *http.Request) string { + value := r.Header.Get("Authorization") + if len(value) < 8 || !strings.EqualFold(value[:7], "Bearer ") { + return "" + } + return strings.TrimSpace(value[7:]) +} + +func validIdentifier(value string, max int) bool { + return value != "" && len(value) <= max && !strings.ContainsAny(value, "\r\n\x00") +} + +func terminal(status TaskStatus) bool { + switch status { + case TaskSucceeded, TaskFailed, TaskCancelled, TaskExpired, TaskResultUnconfirmed: + return true + default: + return false + } +} + +func leaseActive(task Task, now time.Time) bool { + return task.LeaseOwner != "" && task.LeaseExpiresAt != nil && now.Before(*task.LeaseExpiresAt) +} + +func markUnconfirmed(task *Task, reason string, now time.Time) { + task.Status = TaskResultUnconfirmed + task.StateVersion++ + task.UpdatedAt = now + task.LeaseOwner = "" + task.LeaseExpiresAt = nil + task.Result = &TaskResult{TaskID: task.TaskID, AccountID: task.AccountID, LeaseGeneration: task.LeaseGeneration, Status: TaskResultUnconfirmed, ErrorCode: reason, Message: "The control plane could not prove that the previous executor stopped before the lease expired.", CorrelationID: task.LastCorrelationID} +} + +func validCapabilities(capabilities []string) bool { + for _, capability := range capabilities { + if !validIdentifier(capability, 80) { + return false + } + } + return true +} + +func validAccountSummaries(accounts []AccountSummary) bool { + seen := make(map[string]struct{}, len(accounts)) + for _, account := range accounts { + if !validIdentifier(account.AccountID, 200) || account.AllowedGroupCount < 0 || account.AllowedPrivateCount < 0 { + return false + } + if _, exists := seen[account.AccountID]; exists { + return false + } + seen[account.AccountID] = struct{}{} + } + return len(accounts) <= 100 +} + +func validNodeStatus(status NodeStatus) bool { + switch status { + case NodeRegistered, NodeOnline, NodeDegraded, NodeOffline, NodeSessionLocked, NodeWechatNotRunning, NodeWechatNotLogged: + return true + default: + return false + } +} + +func nodeHasAccount(node Node, accountID string) bool { + for _, account := range node.Accounts { + if account.AccountID == accountID && account.Active && account.Verified { + return true + } + } + return false +} + +func validSendTextPayload(payload jsonRaw) bool { + if len(payload) == 0 || len(payload) > 64*1024 || !json.Valid(payload) { + return false + } + var value struct { + TargetID string `json:"target_id"` + Text string `json:"text"` + Confirmed bool `json:"confirmed"` + } + if json.Unmarshal(payload, &value) != nil { + return false + } + return validIdentifier(value.TargetID, 512) && len(value.Text) > 0 && len(value.Text) <= 4000 && value.Confirmed +} + +func eventHash(event MessageEvent) string { + value := struct { + NodeID string `json:"node_id"` + AccountID string `json:"account_id"` + ChatID string `json:"chat_id"` + ChatType ChatType `json:"chat_type"` + EventSeq int64 `json:"event_seq"` + EventType string `json:"event_type"` + OccurredAt time.Time `json:"occurred_at"` + Content string `json:"content"` + ConfigVersion int64 `json:"config_version"` + }{event.NodeID, event.AccountID, event.ChatID, event.ChatType, event.EventSeq, event.EventType, event.OccurredAt, event.Content, event.ConfigVersion} + data, _ := json.Marshal(value) + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func appendAudit(entries []AuditEntry, principal, action, resource, correlationID, outcome string, at time.Time) []AuditEntry { + entries = append(entries, AuditEntry{ID: randomID(), At: at, Principal: principal, Action: action, Resource: resource, CorrelationID: correlationID, Outcome: outcome}) + if len(entries) > 10000 { + entries = entries[len(entries)-10000:] + } + return entries +} + +func queryLimit(raw string) int { + if value, err := strconv.Atoi(raw); err == nil && value >= 1 && value <= 200 { + return value + } + return 50 +} + +func queryWaitSeconds(raw string) int { + if value, err := strconv.Atoi(raw); err == nil && value >= 1 && value <= 30 { + return value + } + return 0 +} + +func firstNonEmpty(value, fallback string) string { + if value != "" { + return value + } + return fallback +} + +func randomID() string { + buffer := make([]byte, 16) + if _, err := rand.Read(buffer); err != nil { + return fmt.Sprintf("fallback-%d", time.Now().UnixNano()) + } + return base64.RawURLEncoding.EncodeToString(buffer) +} diff --git a/control-plane/server_test.go b/control-plane/server_test.go new file mode 100644 index 0000000..c8b4221 --- /dev/null +++ b/control-plane/server_test.go @@ -0,0 +1,343 @@ +package controlplane + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestReactFrontendIsEmbedded(t *testing.T) { + server, err := NewServer(ServerConfig{DataFile: filepath.Join(t.TempDir(), "state.json")}) + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + response, err := http.Get(httpServer.URL + "/") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || !strings.Contains(string(body), "
") || !strings.Contains(string(body), "/assets/") { + t.Fatalf("React index was not served: status=%d body=%s", response.StatusCode, body) + } + if strings.Contains(response.Header.Get("Content-Security-Policy"), "unsafe-inline") { + t.Fatal("React frontend still permits inline scripts") + } + marker := `src="/assets/` + start := strings.Index(string(body), marker) + if start < 0 { + t.Fatal("React asset reference is missing") + } + start += len(marker) + end := strings.Index(string(body)[start:], "\"") + if end < 0 { + t.Fatal("React asset reference is malformed") + } + assetPath := string(body)[start : start+end] + asset, err := http.Get(httpServer.URL + "/assets/" + assetPath) + if err != nil { + t.Fatal(err) + } + defer asset.Body.Close() + if asset.StatusCode != http.StatusOK { + t.Fatalf("React asset status = %d", asset.StatusCode) + } +} + +func TestNodeWebTaskAndEventFlow(t *testing.T) { + t.Parallel() + dataFile := filepath.Join(t.TempDir(), "control-plane.json") + server, err := NewServer(ServerConfig{ + DataFile: dataFile, + NodeTokens: map[string]string{"node-1": "node-secret", "node-2": "other-secret"}, + WebUsers: map[string]string{"admin": "web-secret"}, + LeaseTTL: time.Minute, + HeartbeatTimeout: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + client := httpServer.Client() + + if response := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes", "", nil); response.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated node list status = %d", response.Code) + } + login := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/auth/login", "", map[string]string{"username": "admin", "password": "web-secret"}) + if login.Code != http.StatusOK { + t.Fatalf("login status = %d: %s", login.Code, login.Body.String()) + } + var loginBody struct { + AccessToken string `json:"access_token"` + } + decodeBody(t, login, &loginBody) + if loginBody.AccessToken == "" { + t.Fatal("login returned no session token") + } + webAuth := "Bearer " + loginBody.AccessToken + + registration := NodeRegistration{NodeID: "node-1", AgentVersion: "test", ProtocolVersion: ProtocolVersion, + Capabilities: []string{"heartbeat", "poll-tasks", "send-text"}, ReportingConfigVersion: 1, + Accounts: []AccountSummary{{AccountID: "account-a", Active: true, Verified: true}}} + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer node-secret", registration); response.Code != http.StatusOK { + t.Fatalf("registration status = %d: %s", response.Code, response.Body.String()) + } + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/heartbeat", "Bearer node-secret", Heartbeat{ + NodeID: "node-1", AgentVersion: "test", ProtocolVersion: ProtocolVersion, NodeStatus: NodeOnline, + WechatRunning: true, WechatLoggedIn: true, ActiveAccountID: "account-a", ReportingConfigVersion: 1, CorrelationID: "heartbeat-1", + }); response.Code != http.StatusOK { + t.Fatalf("heartbeat status = %d", response.Code) + } + + payload := json.RawMessage(`{"target_id":"chat-a","text":"hello","confirmed":true}`) + created := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", webAuth, TaskSubmission{ + NodeID: "node-1", AccountID: "account-a", Kind: "send-text", IdempotencyKey: "same-key", Payload: payload, + }) + if created.Code != http.StatusAccepted { + t.Fatalf("task create status = %d: %s", created.Code, created.Body.String()) + } + var createdBody TaskSubmissionResponse + decodeBody(t, created, &createdBody) + if createdBody.TaskID == "" || createdBody.Status != TaskPending { + t.Fatalf("unexpected task create response: %+v", createdBody) + } + + poll := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/node-1/tasks?account_id=account-a", "Bearer node-secret", nil) + if poll.Code != http.StatusOK { + t.Fatalf("poll status = %d", poll.Code) + } + var batch TaskBatch + decodeBody(t, poll, &batch) + if len(batch.Tasks) != 1 || batch.Tasks[0].LeaseGeneration != 1 { + t.Fatalf("unexpected task batch: %+v", batch) + } + task := batch.Tasks[0] + ack := TaskAck{TaskID: task.TaskID, AccountID: task.AccountID, LeaseGeneration: task.LeaseGeneration} + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+task.TaskID+"/ack", "Bearer node-secret", ack); response.Code != http.StatusOK { + t.Fatalf("ack status = %d: %s", response.Code, response.Body.String()) + } + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+task.TaskID+"/start", "Bearer node-secret", ack); response.Code != http.StatusOK { + t.Fatalf("start status = %d: %s", response.Code, response.Body.String()) + } + result := TaskResult{TaskID: task.TaskID, AccountID: task.AccountID, LeaseGeneration: task.LeaseGeneration, Status: TaskSucceeded, HasSideEffect: true, CorrelationID: "result-1"} + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+task.TaskID+"/result", "Bearer node-secret", result); response.Code != http.StatusOK { + t.Fatalf("result status = %d: %s", response.Code, response.Body.String()) + } + + duplicate := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", webAuth, TaskSubmission{ + NodeID: "node-1", AccountID: "account-a", Kind: "send-text", IdempotencyKey: "same-key", Payload: payload, + }) + var duplicateBody TaskSubmissionResponse + decodeBody(t, duplicate, &duplicateBody) + if duplicate.Code != http.StatusAccepted || !duplicateBody.Duplicate || duplicateBody.TaskID != task.TaskID { + t.Fatalf("unexpected duplicate response: %+v", duplicateBody) + } + conflict := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", webAuth, TaskSubmission{ + NodeID: "node-1", AccountID: "account-a", Kind: "send-text", IdempotencyKey: "same-key", + Payload: json.RawMessage(`{"target_id":"chat-b","text":"different","confirmed":true}`), + }) + if conflict.Code != http.StatusConflict { + t.Fatalf("idempotency conflict status = %d", conflict.Code) + } + + unauthorizedEvent := MessageEvent{NodeID: "node-1", AccountID: "account-a", ChatID: "chat-not-allowed", ChatType: ChatPrivate, + EventSeq: 1, EventType: "message", OccurredAt: time.Now().UTC(), Content: "must-not-store", ConfigVersion: 1, AuthorizationVersion: 1, Authorized: false} + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/events", "Bearer node-secret", unauthorizedEvent); response.Code != http.StatusForbidden { + t.Fatalf("unauthorized event status = %d", response.Code) + } + authorizedEvent := unauthorizedEvent + authorizedEvent.ChatID = "chat-a" + authorizedEvent.Authorized = true + accepted := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/events", "Bearer node-secret", authorizedEvent) + if accepted.Code != http.StatusAccepted { + t.Fatalf("authorized event status = %d: %s", accepted.Code, accepted.Body.String()) + } + duplicateEvent := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/events", "Bearer node-secret", authorizedEvent) + var duplicateEventBody EventReceipt + decodeBody(t, duplicateEvent, &duplicateEventBody) + if duplicateEvent.Code != http.StatusAccepted || !duplicateEventBody.Duplicate { + t.Fatalf("unexpected duplicate event: %+v", duplicateEventBody) + } + crossAccount := authorizedEvent + crossAccount.AccountID = "account-b" + crossAccount.ChatID = "chat-a" + crossAccount.EventSeq = 1 + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/events", "Bearer node-secret", crossAccount); response.Code != http.StatusAccepted { + t.Fatalf("cross-account event status = %d", response.Code) + } + changed := authorizedEvent + changed.Content = "different" + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/events", "Bearer node-secret", changed); response.Code != http.StatusConflict { + t.Fatalf("event idempotency conflict status = %d", response.Code) + } + + events := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/events?limit=20", webAuth, nil) + if events.Code != http.StatusOK || bytes.Contains(events.Body.Bytes(), []byte("chat-not-allowed")) { + t.Fatalf("event listing leaked rejected event: status=%d body=%s", events.Code, events.Body.String()) + } + var eventList struct { + Events []StoredEvent `json:"events"` + } + decodeBody(t, events, &eventList) + if len(eventList.Events) != 2 { + t.Fatalf("expected two account-scoped events, got %d", len(eventList.Events)) + } + + restarted, err := NewServer(ServerConfig{DataFile: dataFile, NodeTokens: map[string]string{"node-1": "node-secret"}, WebUsers: map[string]string{"admin": "web-secret"}}) + if err != nil { + t.Fatal(err) + } + restartedHTTP := httptest.NewServer(restarted.Handler()) + defer restartedHTTP.Close() + loginAgain := doJSON(t, restartedHTTP.Client(), http.MethodPost, restartedHTTP.URL+"/v1/auth/login", "", map[string]string{"username": "admin", "password": "web-secret"}) + var loginAgainBody struct { + AccessToken string `json:"access_token"` + } + decodeBody(t, loginAgain, &loginAgainBody) + stored := doJSON(t, restartedHTTP.Client(), http.MethodGet, restartedHTTP.URL+"/v1/tasks/"+task.TaskID, "Bearer "+loginAgainBody.AccessToken, nil) + var storedTask Task + decodeBody(t, stored, &storedTask) + if stored.Code != http.StatusOK || storedTask.Status != TaskSucceeded { + t.Fatalf("task was not durable across restart: status=%d task=%+v", stored.Code, storedTask) + } +} + +func TestRemoteReadRoutesUseTheTaskQueueAndRetainReadContent(t *testing.T) { + server, err := NewServer(ServerConfig{ + DataFile: filepath.Join(t.TempDir(), "control-plane.json"), + NodeTokens: map[string]string{"node-1": "node-secret"}, + WebUsers: map[string]string{"admin": "web-secret"}, + LeaseTTL: time.Minute, + }) + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + client := httpServer.Client() + + login := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/auth/login", "", map[string]string{"username": "admin", "password": "web-secret"}) + var loginBody struct { + AccessToken string `json:"access_token"` + } + decodeBody(t, login, &loginBody) + webAuth := "Bearer " + loginBody.AccessToken + registration := NodeRegistration{NodeID: "node-1", AgentVersion: "test", ProtocolVersion: ProtocolVersion, + Capabilities: []string{"heartbeat", "poll-tasks", "read-sessions", "read-contacts", "read-messages"}, + Accounts: []AccountSummary{{AccountID: "account-a", Active: true, Verified: true}}} + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer node-secret", registration); response.Code != http.StatusOK { + t.Fatalf("registration status = %d: %s", response.Code, response.Body.String()) + } + + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/reads/sessions", "", ReadTaskSubmission{ + NodeID: "node-1", AccountID: "account-a", IdempotencyKey: "read-unauthorized", + }); response.Code != http.StatusUnauthorized { + t.Fatalf("unauthorized read status = %d", response.Code) + } + created := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/reads/sessions", webAuth, ReadTaskSubmission{ + NodeID: "node-1", AccountID: "account-a", IdempotencyKey: "read-sessions-1", Limit: 20, + }) + if created.Code != http.StatusAccepted { + t.Fatalf("read create status = %d: %s", created.Code, created.Body.String()) + } + var createdBody TaskSubmissionResponse + decodeBody(t, created, &createdBody) + stored := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/tasks/"+createdBody.TaskID, webAuth, nil) + var createdTask Task + decodeBody(t, stored, &createdTask) + if createdTask.Kind != "read-sessions" || createdTask.Payload == nil { + t.Fatalf("unexpected read task: %+v", createdTask) + } + + poll := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/node-1/tasks?account_id=account-a", "Bearer node-secret", nil) + var batch TaskBatch + decodeBody(t, poll, &batch) + if len(batch.Tasks) != 1 { + t.Fatalf("expected one read task, got %d", len(batch.Tasks)) + } + task := batch.Tasks[0] + ack := TaskAck{TaskID: task.TaskID, AccountID: task.AccountID, LeaseGeneration: task.LeaseGeneration} + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+task.TaskID+"/ack", "Bearer node-secret", ack); response.Code != http.StatusOK { + t.Fatalf("ack status = %d: %s", response.Code, response.Body.String()) + } + if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+task.TaskID+"/start", "Bearer node-secret", ack); response.Code != http.StatusOK { + t.Fatalf("start status = %d: %s", response.Code, response.Body.String()) + } + content := json.RawMessage(`{"items":[],"limit":20,"offset":0,"hasMore":false}`) + result := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+task.TaskID+"/result", "Bearer node-secret", TaskResult{ + TaskID: task.TaskID, AccountID: task.AccountID, LeaseGeneration: task.LeaseGeneration, + Status: TaskSucceeded, Content: content, CorrelationID: "read-result-1", + }) + if result.Code != http.StatusOK { + t.Fatalf("read result status = %d: %s", result.Code, result.Body.String()) + } + var resultTask Task + decodeBody(t, result, &resultTask) + if resultTask.Result == nil || string(resultTask.Result.Content) != string(content) { + t.Fatalf("read content was not retained: %+v", resultTask.Result) + } + + invalid := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/reads/messages", webAuth, ReadTaskSubmission{ + NodeID: "node-1", AccountID: "account-a", IdempotencyKey: "read-messages-invalid", + }) + if invalid.Code != http.StatusBadRequest { + t.Fatalf("invalid message read status = %d: %s", invalid.Code, invalid.Body.String()) + } +} + +type responseBody struct { + *http.Response + Body *bytes.Buffer + Code int +} + +func doJSON(t *testing.T, client *http.Client, method, endpoint, authorization string, value any) responseBody { + t.Helper() + var body *bytes.Reader + if value == nil { + body = bytes.NewReader(nil) + } else { + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + body = bytes.NewReader(data) + } + request, err := http.NewRequest(method, endpoint, body) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + if authorization != "" { + request.Header.Set("Authorization", authorization) + } + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + data, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return responseBody{Response: response, Body: bytes.NewBuffer(data), Code: response.StatusCode} +} + +func decodeBody(t *testing.T, response responseBody, target any) { + t.Helper() + if err := json.Unmarshal(response.Body.Bytes(), target); err != nil { + t.Fatalf("decode response %d: %v; body=%s", response.Code, err, response.Body.String()) + } +} diff --git a/control-plane/store.go b/control-plane/store.go new file mode 100644 index 0000000..2b9b1d8 --- /dev/null +++ b/control-plane/store.go @@ -0,0 +1,131 @@ +package controlplane + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +// Store is a small durable JSON store for the single-process control-plane MVP. +// The file is an implementation detail; callers only observe transactional methods. +type Store struct { + path string + mu sync.Mutex + state PersistedState +} + +func OpenStore(path string) (*Store, error) { + if path == "" { + return nil, errors.New("data file is required") + } + store := &Store{path: filepath.Clean(path), state: PersistedState{Nodes: map[string]Node{}, Tasks: map[string]Task{}, Events: []StoredEvent{}, Audit: []AuditEntry{}}} + data, err := os.ReadFile(store.path) + if errors.Is(err, os.ErrNotExist) { + return store, nil + } + if err != nil { + return nil, fmt.Errorf("read data file: %w", err) + } + if len(data) == 0 { + return store, nil + } + if err := json.Unmarshal(data, &store.state); err != nil { + return nil, fmt.Errorf("decode data file: %w", err) + } + store.ensureMaps() + return store, nil +} + +func (s *Store) Snapshot() PersistedState { + s.mu.Lock() + defer s.mu.Unlock() + return cloneState(s.state) +} + +func (s *Store) Mutate(fn func(*PersistedState) error) error { + s.mu.Lock() + defer s.mu.Unlock() + s.ensureMaps() + if err := fn(&s.state); err != nil { + return err + } + return s.saveLocked() +} + +func (s *Store) Read(fn func(PersistedState) error) error { + s.mu.Lock() + defer s.mu.Unlock() + return fn(cloneState(s.state)) +} + +func (s *Store) ensureMaps() { + if s.state.Nodes == nil { + s.state.Nodes = map[string]Node{} + } + if s.state.Tasks == nil { + s.state.Tasks = map[string]Task{} + } + if s.state.Events == nil { + s.state.Events = []StoredEvent{} + } + if s.state.Audit == nil { + s.state.Audit = []AuditEntry{} + } +} + +func (s *Store) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("create data directory: %w", err) + } + data, err := json.MarshalIndent(s.state, "", " ") + if err != nil { + return fmt.Errorf("encode data file: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(s.path), ".wxagent-control-plane-*") + if err != nil { + return fmt.Errorf("create temporary data file: %w", err) + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return fmt.Errorf("protect temporary data file: %w", err) + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return fmt.Errorf("write data file: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("sync data file: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close data file: %w", err) + } + if err := os.Rename(temporaryName, s.path); err != nil { + return fmt.Errorf("replace data file: %w", err) + } + return nil +} + +func cloneState(source PersistedState) PersistedState { + data, _ := json.Marshal(source) + var copy PersistedState + _ = json.Unmarshal(data, ©) + if copy.Nodes == nil { + copy.Nodes = map[string]Node{} + } + if copy.Tasks == nil { + copy.Tasks = map[string]Task{} + } + if copy.Events == nil { + copy.Events = []StoredEvent{} + } + if copy.Audit == nil { + copy.Audit = []AuditEntry{} + } + return copy +} diff --git a/control-plane/web/dist/assets/index-0iXIpojA.css b/control-plane/web/dist/assets/index-0iXIpojA.css new file mode 100644 index 0000000..a282aa9 --- /dev/null +++ b/control-plane/web/dist/assets/index-0iXIpojA.css @@ -0,0 +1 @@ +:root{font-family:Manrope,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#182334;background:#f3f6fa;font-synthesis:none;text-rendering:optimizeLegibility;--navy: #0e1a2d;--ink: #182334;--muted: #7c899b;--line: #e7ecf2;--panel: #ffffff;--blue: #2e6df6;--green: #20a477;--orange: #e09235;--red: #d85c65;--purple: #8967e8}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;background:#f3f6fa}button,input,textarea,select{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.55}.login-page{min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 20% 10%,#dce8ff 0,transparent 34%),linear-gradient(135deg,#f3f6fa,#eef2f7)}.login-panel{width:min(440px,100%);padding:42px;border:1px solid rgba(255,255,255,.7);border-radius:22px;background:#ffffffdb;box-shadow:0 26px 80px #192c4e24;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.brand-mark{display:grid;place-items:center;width:46px;height:46px;border-radius:13px;color:#fff;background:linear-gradient(140deg,#3c7cff,#6041d8);font-size:21px;font-weight:800;box-shadow:0 9px 18px #3d67ec47}.brand-mark.small{width:36px;height:36px;border-radius:10px;font-size:17px;box-shadow:none}.eyebrow{color:#8a96a7;font:500 10px/1.4 DM Mono,monospace;letter-spacing:.14em}.login-panel h1{margin:19px 0 9px;font-size:32px;letter-spacing:-.04em}.muted{margin:0;color:var(--muted);font-size:14px;line-height:1.7}.login-form{display:grid;gap:16px;margin-top:30px}label{display:grid;gap:7px;color:#647287;font-size:12px;font-weight:700}input,textarea,select{width:100%;border:1px solid #dce3eb;border-radius:9px;outline:none;color:var(--ink);background:#fff;transition:border-color .15s,box-shadow .15s}input,select{height:42px;padding:0 12px}textarea{padding:11px 12px;resize:vertical}input:focus,textarea:focus,select:focus{border-color:#7da2ff;box-shadow:0 0 0 3px #2e6df61a}.form-error,.global-error{color:#a73b49;border:1px solid #f1cbd0;border-radius:8px;background:#fff4f5}.form-error{padding:10px 12px;font-size:12px}.login-footnote{display:flex;gap:8px;align-items:center;margin-top:24px;color:#9aa5b4;font-size:11px}.secure-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#31b887;box-shadow:0 0 0 4px #31b8871f}.app-shell{display:flex;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;z-index:2;display:flex;flex-direction:column;width:240px;padding:24px 16px 16px;color:#c2ccda;background:var(--navy)}.sidebar-brand{display:flex;align-items:center;gap:11px;padding:0 10px 38px;color:#fff}.sidebar-brand strong{display:block;font-size:15px;letter-spacing:-.02em}.sidebar-brand span{display:block;margin-top:2px;color:#71819b;font:10px DM Mono,monospace}.nav-caption{padding:0 12px 10px;color:#667690;font:500 10px DM Mono,monospace;letter-spacing:.14em;text-transform:uppercase}nav{display:grid;gap:4px}.nav-item{display:flex;gap:12px;align-items:center;height:44px;padding:0 12px;border:0;border-radius:9px;color:#8e9bb0;text-align:left;background:transparent;font-size:13px;font-weight:600}.nav-item:hover{color:#fff;background:#ffffff0f}.nav-item.active{color:#fff;background:#273b60;box-shadow:inset 3px 0 #608cff}.nav-icon{width:17px;color:#6885b9;font-size:17px;text-align:center}.nav-item.active .nav-icon{color:#94b1ff}.sidebar-spacer{flex:1}.connection-hint{display:flex;gap:11px;align-items:center;margin:12px 4px 16px;padding:13px 11px;border:1px solid rgba(125,154,204,.16);border-radius:10px;background:#ffffff09}.connection-hint strong,.connection-hint small,.user-name strong,.user-name small{display:block}.connection-hint strong{color:#d2dbeb;font-size:11px}.connection-hint small{margin-top:4px;color:#70809a;font-size:10px}.user-menu{display:flex;gap:9px;align-items:center;padding:14px 7px 0;border-top:1px solid rgba(255,255,255,.08)}.avatar,.node-avatar{display:grid;place-items:center;flex:0 0 auto;width:30px;height:30px;border-radius:9px;color:#315ac3;background:#dfe9ff;font-size:11px;font-weight:800}.user-name{flex:1;min-width:0}.user-name strong{overflow:hidden;color:#e0e7f3;font-size:11px;text-overflow:ellipsis;white-space:nowrap}.user-name small{margin-top:3px;color:#70809a;font-size:10px}.user-menu button{border:0;color:#71809a;background:transparent;font-size:19px}.main-area{width:calc(100% - 240px);min-width:0;margin-left:240px}.topbar{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:43px 52px 32px;background:#fff;border-bottom:1px solid var(--line)}.topbar h2{margin:8px 0 5px;color:var(--ink);font-size:26px;letter-spacing:-.04em}.topbar p{margin:0;color:#8a96a7;font-size:12px}.refresh-button{display:flex;gap:8px;align-items:center;height:37px;padding:0 14px;border:1px solid #dce3ec;border-radius:8px;color:#506078;background:#fff;font-size:12px;font-weight:700}.refresh-button:hover{border-color:#a9bfe9;color:#2e6df6;background:#f8faff}.refresh-button span{font-size:18px;line-height:1}.spin{animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.page-content{max-width:1500px;padding:33px 52px 24px}.global-error{display:flex;gap:9px;align-items:center;margin:18px 52px 0;padding:11px 14px;font-size:12px}.global-error span{display:grid;place-items:center;width:17px;height:17px;border-radius:50%;color:#fff;background:#cf5b66;font-weight:800}.global-error button{margin-left:auto;border:0;color:inherit;background:transparent;font-size:18px}.metric-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:15px}.metric-card{position:relative;overflow:hidden;min-height:135px;padding:20px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.metric-card:after{position:absolute;right:-25px;bottom:-38px;width:110px;height:110px;border-radius:50%;content:"";opacity:.1}.metric-card.blue:after{background:#2e6df6}.metric-card.purple:after{background:#8967e8}.metric-card.green:after{background:#20a477}.metric-card.orange:after{background:#e09235}.metric-card.slate:after{background:#7c899b}.metric-label{color:#8b97a7;font-size:12px;font-weight:700}.metric-value{margin-top:16px;color:var(--ink);font-size:29px;font-weight:800;letter-spacing:-.05em}.metric-detail{margin-top:9px;color:#9aa5b4;font-size:11px}.content-grid{display:grid;gap:16px;margin-top:16px}.two-col{grid-template-columns:repeat(2,1fr)}.panel{min-width:0;padding:22px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.full-panel{padding:24px}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:19px}.panel-heading h3{margin:0;color:var(--ink);font-size:15px;letter-spacing:-.02em}.panel-heading p{margin:6px 0 0;color:#9aa5b4;font-size:11px}.heading-actions{display:flex;gap:8px;align-items:center}.heading-actions select{width:auto;height:36px;color:#667489;font-size:11px}.table-count{color:#8f9aac;font-size:11px}.text-button{padding:0;border:0;color:#4b76e7;background:transparent;font-size:11px;font-weight:700}.text-button:hover{color:#1e4bc5}.compact-list{display:grid}.compact-row{display:flex;gap:11px;align-items:center;min-width:0;padding:12px 0;border-top:1px solid #eff2f5}.compact-row:first-child{border-top:0}.row-main{flex:1;min-width:0}.row-main strong,.row-main span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.row-main strong{color:#334156;font-size:12px}.row-main span{margin-top:4px;color:#9aa5b4;font-size:10px}.event-icon{display:grid;place-items:center;width:30px;height:30px;border-radius:9px;color:#269b79;background:#e5f7f0}.event-preview{max-width:34%;overflow:hidden;color:#788598;font-size:10px;text-overflow:ellipsis;white-space:nowrap}.status-badge{display:inline-flex;gap:6px;align-items:center;padding:5px 8px;border-radius:999px;font-size:10px;font-weight:700;white-space:nowrap}.status-badge i{width:5px;height:5px;border-radius:50%;background:currentColor}.status-badge.positive{color:#168a68;background:#e7f8f2}.status-badge.warning{color:#b26e1c;background:#fff4e3}.status-badge.negative{color:#c24d5d;background:#fff0f1}.status-badge.neutral{color:#6f7d90;background:#edf1f5}.table-wrap{overflow-x:auto}.table-wrap table{width:100%;border-collapse:collapse;min-width:760px}.table-wrap th{padding:0 11px 12px;color:#99a4b2;font:500 10px DM Mono,monospace;text-align:left;text-transform:uppercase;white-space:nowrap}.table-wrap td{padding:14px 11px;border-top:1px solid #eef1f4;color:#617086;font-size:11px;vertical-align:middle}.table-wrap tr:hover td{background:#fafcff}.cell-primary{display:flex;gap:9px;align-items:center;min-width:150px}.cell-primary strong,.cell-primary small,.cell-note{display:block}.cell-primary strong{color:#344156;font-size:11px}.cell-primary small{margin-top:4px;color:#a1aab7;font-size:10px}.cell-note{margin-top:5px;color:#a1aab7;font-size:10px}.inline-positive{color:#218e70;font-weight:700}.inline-muted{color:#929ead}.capability-count{color:#4b76e7;font-weight:800}.task-symbol{display:grid;place-items:center;width:29px;height:29px;border-radius:8px;color:#7454d6;background:#eeeaff;font-size:16px}.danger-button{padding:5px 9px;border:1px solid #f2c8ce;border-radius:6px;color:#c24d5d;background:#fff8f8;font-size:10px;font-weight:700}.danger-button:hover{color:#fff;background:#d85c65}.error-code{color:#be5360;font-family:DM Mono,monospace;font-size:10px}.cancel-note{color:#b87723}.seq-pill{padding:4px 7px;border-radius:5px;color:#5574b6;background:#edf2ff;font:10px DM Mono,monospace}.event-content{display:block;max-width:270px;overflow:hidden;color:#425168;text-overflow:ellipsis;white-space:nowrap}.search-input{width:240px;height:35px;font-size:11px}.task-id{min-width:175px}.primary-button,.secondary-button{height:37px;padding:0 14px;border-radius:8px;font-size:11px;font-weight:800}.primary-button{border:1px solid #2e6df6;color:#fff;background:#2e6df6;box-shadow:0 5px 12px #2e6df62e}.primary-button:hover{background:#205bdc}.secondary-button{border:1px solid #dce3eb;color:#647287;background:#fff}.full{width:100%;height:44px;margin-top:4px;font-size:12px}.empty-state{display:grid;justify-items:center;padding:47px 15px;color:#a0aab8;text-align:center}.empty-icon{color:#c2ccd9;font-size:30px}.empty-state p{margin:12px 0 0;font-size:12px}.timeline{display:grid}.timeline-row{display:flex;gap:13px;align-items:center;min-height:64px;border-bottom:1px solid #eef1f4}.timeline-dot{width:8px;height:8px;border:2px solid #7294ef;border-radius:50%;background:#fff}.timeline-main{flex:1}.timeline-main strong,.timeline-main span{display:block}.timeline-main strong{color:#44526a;font-size:11px}.timeline-main span{margin-top:5px;color:#a0a9b6;font-size:10px}.timeline-meta{display:flex;gap:13px;align-items:center;color:#9aa5b4;font-size:10px}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:5;display:grid;place-items:center;padding:20px;background:#0e1a2d7a}.modal{width:min(540px,100%);padding:25px;border-radius:14px;background:#fff;box-shadow:0 25px 70px #06122847}.modal-heading{display:flex;justify-content:space-between;gap:16px;margin-bottom:21px}.modal-heading h3{margin:8px 0 5px;font-size:19px}.modal-heading p{margin:0;color:#96a0af;font-size:11px}.close-button{align-self:flex-start;border:0;color:#99a3b0;background:transparent;font-size:24px}.task-form{display:grid;gap:14px}.modal-actions{display:flex;justify-content:flex-end;gap:8px;padding-top:5px}.footer{display:flex;justify-content:space-between;gap:20px;padding:0 52px 30px;color:#a7b0bc;font:10px DM Mono,monospace}@media (max-width: 1100px){.metric-grid{grid-template-columns:repeat(2,1fr)}.two-col{grid-template-columns:1fr}.topbar,.page-content{padding-left:30px;padding-right:30px}.global-error{margin-left:30px;margin-right:30px}.footer{padding-left:30px;padding-right:30px}}@media (max-width: 720px){.sidebar{position:static;width:100%;min-height:auto;padding:15px}.sidebar-brand{padding-bottom:18px}.sidebar-spacer,.connection-hint{display:none}nav{grid-template-columns:repeat(5,1fr)}.nav-item{justify-content:center;height:36px;padding:0 4px;font-size:0}.nav-icon{font-size:17px}.user-menu{margin-top:13px}.app-shell{display:block}.main-area{width:100%;margin-left:0}.topbar{padding:24px 18px}.topbar h2{font-size:22px}.page-content{padding:20px 18px}.global-error{margin:15px 18px 0}.metric-grid{gap:9px}.metric-card{min-height:116px;padding:14px}.metric-value{margin-top:12px;font-size:23px}.panel,.full-panel{padding:16px}.panel-heading{display:block}.heading-actions{margin-top:13px}.heading-actions select{flex:1}.search-input{width:100%;margin-top:14px}.footer{display:block;padding:0 18px 20px;line-height:1.8}.footer span{display:block}} diff --git a/control-plane/web/dist/assets/index-BHaw52zT.js b/control-plane/web/dist/assets/index-BHaw52zT.js new file mode 100644 index 0000000..07217ac --- /dev/null +++ b/control-plane/web/dist/assets/index-BHaw52zT.js @@ -0,0 +1,40 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const u of i.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&r(u)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();var Xo={exports:{}},il={},Go={exports:{}},L={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Jn=Symbol.for("react.element"),ac=Symbol.for("react.portal"),cc=Symbol.for("react.fragment"),fc=Symbol.for("react.strict_mode"),dc=Symbol.for("react.profiler"),pc=Symbol.for("react.provider"),hc=Symbol.for("react.context"),mc=Symbol.for("react.forward_ref"),vc=Symbol.for("react.suspense"),yc=Symbol.for("react.memo"),gc=Symbol.for("react.lazy"),Uu=Symbol.iterator;function wc(e){return e===null||typeof e!="object"?null:(e=Uu&&e[Uu]||e["@@iterator"],typeof e=="function"?e:null)}var Zo={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Jo=Object.assign,qo={};function sn(e,t,n){this.props=e,this.context=t,this.refs=qo,this.updater=n||Zo}sn.prototype.isReactComponent={};sn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};sn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function bo(){}bo.prototype=sn.prototype;function Wi(e,t,n){this.props=e,this.context=t,this.refs=qo,this.updater=n||Zo}var Hi=Wi.prototype=new bo;Hi.constructor=Wi;Jo(Hi,sn.prototype);Hi.isPureReactComponent=!0;var $u=Array.isArray,es=Object.prototype.hasOwnProperty,Qi={current:null},ts={key:!0,ref:!0,__self:!0,__source:!0};function ns(e,t,n){var r,l={},i=null,u=null;if(t!=null)for(r in t.ref!==void 0&&(u=t.ref),t.key!==void 0&&(i=""+t.key),t)es.call(t,r)&&!ts.hasOwnProperty(r)&&(l[r]=t[r]);var o=arguments.length-2;if(o===1)l.children=n;else if(1>>1,Z=_[Q];if(0>>1;Ql(El,z))ytl(rr,El)?(_[Q]=rr,_[yt]=z,Q=yt):(_[Q]=El,_[vt]=z,Q=vt);else if(ytl(rr,z))_[Q]=rr,_[yt]=z,Q=yt;else break e}}return P}function l(_,P){var z=_.sortIndex-P.sortIndex;return z!==0?z:_.id-P.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var u=Date,o=u.now();e.unstable_now=function(){return u.now()-o}}var s=[],d=[],m=1,v=null,h=3,w=!1,x=!1,S=!1,O=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function f(_){for(var P=n(d);P!==null;){if(P.callback===null)r(d);else if(P.startTime<=_)r(d),P.sortIndex=P.expirationTime,t(s,P);else break;P=n(d)}}function y(_){if(S=!1,f(_),!x)if(n(s)!==null)x=!0,kl(k);else{var P=n(d);P!==null&&jl(y,P.startTime-_)}}function k(_,P){x=!1,S&&(S=!1,p(E),E=-1),w=!0;var z=h;try{for(f(P),v=n(s);v!==null&&(!(v.expirationTime>P)||_&&!me());){var Q=v.callback;if(typeof Q=="function"){v.callback=null,h=v.priorityLevel;var Z=Q(v.expirationTime<=P);P=e.unstable_now(),typeof Z=="function"?v.callback=Z:v===n(s)&&r(s),f(P)}else r(s);v=n(s)}if(v!==null)var nr=!0;else{var vt=n(d);vt!==null&&jl(y,vt.startTime-P),nr=!1}return nr}finally{v=null,h=z,w=!1}}var C=!1,N=null,E=-1,M=5,T=-1;function me(){return!(e.unstable_now()-T_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):M=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return h},e.unstable_getFirstCallbackNode=function(){return n(s)},e.unstable_next=function(_){switch(h){case 1:case 2:case 3:var P=3;break;default:P=h}var z=h;h=P;try{return _()}finally{h=z}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,P){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var z=h;h=_;try{return P()}finally{h=z}},e.unstable_scheduleCallback=function(_,P,z){var Q=e.unstable_now();switch(typeof z=="object"&&z!==null?(z=z.delay,z=typeof z=="number"&&0Q?(_.sortIndex=z,t(d,_),n(s)===null&&_===n(d)&&(S?(p(E),E=-1):S=!0,jl(y,z-Q))):(_.sortIndex=Z,t(s,_),x||w||(x=!0,kl(k))),_},e.unstable_shouldYield=me,e.unstable_wrapCallback=function(_){var P=h;return function(){var z=h;h=P;try{return _.apply(this,arguments)}finally{h=z}}}})(os);us.exports=os;var Tc=us.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Lc=D,we=Tc;function g(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Jl=Object.prototype.hasOwnProperty,Rc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Vu={},Bu={};function Oc(e){return Jl.call(Bu,e)?!0:Jl.call(Vu,e)?!1:Rc.test(e)?Bu[e]=!0:(Vu[e]=!0,!1)}function Ic(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Dc(e,t,n,r){if(t===null||typeof t>"u"||Ic(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ae(e,t,n,r,l,i,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=u}var te={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){te[e]=new ae(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];te[t]=new ae(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){te[e]=new ae(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){te[e]=new ae(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){te[e]=new ae(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){te[e]=new ae(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){te[e]=new ae(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){te[e]=new ae(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){te[e]=new ae(e,5,!1,e.toLowerCase(),null,!1,!1)});var Yi=/[\-:]([a-z])/g;function Xi(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Yi,Xi);te[t]=new ae(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Yi,Xi);te[t]=new ae(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Yi,Xi);te[t]=new ae(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!1,!1)});te.xlinkHref=new ae("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!0,!0)});function Gi(e,t,n,r){var l=te.hasOwnProperty(t)?te[t]:null;(l!==null?l.type!==0:r||!(2o||l[u]!==i[o]){var s=` +`+l[u].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=u&&0<=o);break}}}finally{Cl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Sn(e):""}function Mc(e){switch(e.tag){case 5:return Sn(e.type);case 16:return Sn("Lazy");case 13:return Sn("Suspense");case 19:return Sn("SuspenseList");case 0:case 2:case 15:return e=Pl(e.type,!1),e;case 11:return e=Pl(e.type.render,!1),e;case 1:return e=Pl(e.type,!0),e;default:return""}}function ti(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Dt:return"Fragment";case It:return"Portal";case ql:return"Profiler";case Zi:return"StrictMode";case bl:return"Suspense";case ei:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case cs:return(e.displayName||"Context")+".Consumer";case as:return(e._context.displayName||"Context")+".Provider";case Ji:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case qi:return t=e.displayName||null,t!==null?t:ti(e.type)||"Memo";case Je:t=e._payload,e=e._init;try{return ti(e(t))}catch{}}return null}function Fc(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ti(t);case 8:return t===Zi?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ft(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ds(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Uc(e){var t=ds(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,i.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ur(e){e._valueTracker||(e._valueTracker=Uc(e))}function ps(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ds(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Ir(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ni(e,t){var n=t.checked;return W({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Hu(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ft(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function hs(e,t){t=t.checked,t!=null&&Gi(e,"checked",t,!1)}function ri(e,t){hs(e,t);var n=ft(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?li(e,t.type,n):t.hasOwnProperty("defaultValue")&&li(e,t.type,ft(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Qu(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function li(e,t,n){(t!=="number"||Ir(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var kn=Array.isArray;function Kt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=or.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Dn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var _n={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$c=["Webkit","ms","Moz","O"];Object.keys(_n).forEach(function(e){$c.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),_n[t]=_n[e]})});function gs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||_n.hasOwnProperty(e)&&_n[e]?(""+t).trim():t+"px"}function ws(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=gs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Ac=W({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function oi(e,t){if(t){if(Ac[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(g(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(g(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(g(61))}if(t.style!=null&&typeof t.style!="object")throw Error(g(62))}}function si(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ai=null;function bi(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ci=null,Yt=null,Xt=null;function Xu(e){if(e=er(e)){if(typeof ci!="function")throw Error(g(280));var t=e.stateNode;t&&(t=cl(t),ci(e.stateNode,e.type,t))}}function xs(e){Yt?Xt?Xt.push(e):Xt=[e]:Yt=e}function Ss(){if(Yt){var e=Yt,t=Xt;if(Xt=Yt=null,Xu(e),t)for(e=0;e>>=0,e===0?32:31-(Jc(e)/qc|0)|0}var sr=64,ar=4194304;function jn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Ur(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,u=n&268435455;if(u!==0){var o=u&~l;o!==0?r=jn(o):(i&=u,i!==0&&(r=jn(i)))}else u=n&~l,u!==0?r=jn(u):i!==0&&(r=jn(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function qn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Re(t),e[t]=n}function nf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Cn),ro=" ",lo=!1;function Vs(e,t){switch(e){case"keyup":return Lf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Bs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Mt=!1;function Of(e,t){switch(e){case"compositionend":return Bs(t);case"keypress":return t.which!==32?null:(lo=!0,ro);case"textInput":return e=t.data,e===ro&&lo?null:e;default:return null}}function If(e,t){if(Mt)return e==="compositionend"||!ou&&Vs(e,t)?(e=$s(),_r=lu=tt=null,Mt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=so(n)}}function Ks(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ks(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ys(){for(var e=window,t=Ir();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ir(e.document)}return t}function su(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Wf(e){var t=Ys(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ks(n.ownerDocument.documentElement,n)){if(r!==null&&su(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=ao(n,i);var u=ao(n,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Ft=null,vi=null,zn=null,yi=!1;function co(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;yi||Ft==null||Ft!==Ir(r)||(r=Ft,"selectionStart"in r&&su(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),zn&&Vn(zn,r)||(zn=r,r=Vr(vi,"onSelect"),0At||(e.current=ji[At],ji[At]=null,At--)}function F(e,t){At++,ji[At]=e.current,e.current=t}var dt={},ie=ht(dt),de=ht(!1),Nt=dt;function bt(e,t){var n=e.type.contextTypes;if(!n)return dt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function pe(e){return e=e.childContextTypes,e!=null}function Wr(){$(de),$(ie)}function go(e,t,n){if(ie.current!==dt)throw Error(g(168));F(ie,t),F(de,n)}function na(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(g(108,Fc(e)||"Unknown",l));return W({},n,r)}function Hr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||dt,Nt=ie.current,F(ie,e),F(de,de.current),!0}function wo(e,t,n){var r=e.stateNode;if(!r)throw Error(g(169));n?(e=na(e,t,Nt),r.__reactInternalMemoizedMergedChildContext=e,$(de),$(ie),F(ie,e)):$(de),F(de,n)}var Ve=null,fl=!1,Bl=!1;function ra(e){Ve===null?Ve=[e]:Ve.push(e)}function td(e){fl=!0,ra(e)}function mt(){if(!Bl&&Ve!==null){Bl=!0;var e=0,t=I;try{var n=Ve;for(I=1;e>=u,l-=u,Be=1<<32-Re(t)+l|n<E?(M=N,N=null):M=N.sibling;var T=h(p,N,f[E],y);if(T===null){N===null&&(N=M);break}e&&N&&T.alternate===null&&t(p,N),c=i(T,c,E),C===null?k=T:C.sibling=T,C=T,N=M}if(E===f.length)return n(p,N),A&>(p,E),k;if(N===null){for(;EE?(M=N,N=null):M=N.sibling;var me=h(p,N,T.value,y);if(me===null){N===null&&(N=M);break}e&&N&&me.alternate===null&&t(p,N),c=i(me,c,E),C===null?k=me:C.sibling=me,C=me,N=M}if(T.done)return n(p,N),A&>(p,E),k;if(N===null){for(;!T.done;E++,T=f.next())T=v(p,T.value,y),T!==null&&(c=i(T,c,E),C===null?k=T:C.sibling=T,C=T);return A&>(p,E),k}for(N=r(p,N);!T.done;E++,T=f.next())T=w(N,p,E,T.value,y),T!==null&&(e&&T.alternate!==null&&N.delete(T.key===null?E:T.key),c=i(T,c,E),C===null?k=T:C.sibling=T,C=T);return e&&N.forEach(function(fn){return t(p,fn)}),A&>(p,E),k}function O(p,c,f,y){if(typeof f=="object"&&f!==null&&f.type===Dt&&f.key===null&&(f=f.props.children),typeof f=="object"&&f!==null){switch(f.$$typeof){case ir:e:{for(var k=f.key,C=c;C!==null;){if(C.key===k){if(k=f.type,k===Dt){if(C.tag===7){n(p,C.sibling),c=l(C,f.props.children),c.return=p,p=c;break e}}else if(C.elementType===k||typeof k=="object"&&k!==null&&k.$$typeof===Je&&ko(k)===C.type){n(p,C.sibling),c=l(C,f.props),c.ref=gn(p,C,f),c.return=p,p=c;break e}n(p,C);break}else t(p,C);C=C.sibling}f.type===Dt?(c=_t(f.props.children,p.mode,y,f.key),c.return=p,p=c):(y=Or(f.type,f.key,f.props,null,p.mode,y),y.ref=gn(p,c,f),y.return=p,p=y)}return u(p);case It:e:{for(C=f.key;c!==null;){if(c.key===C)if(c.tag===4&&c.stateNode.containerInfo===f.containerInfo&&c.stateNode.implementation===f.implementation){n(p,c.sibling),c=l(c,f.children||[]),c.return=p,p=c;break e}else{n(p,c);break}else t(p,c);c=c.sibling}c=Zl(f,p.mode,y),c.return=p,p=c}return u(p);case Je:return C=f._init,O(p,c,C(f._payload),y)}if(kn(f))return x(p,c,f,y);if(pn(f))return S(p,c,f,y);vr(p,f)}return typeof f=="string"&&f!==""||typeof f=="number"?(f=""+f,c!==null&&c.tag===6?(n(p,c.sibling),c=l(c,f),c.return=p,p=c):(n(p,c),c=Gl(f,p.mode,y),c.return=p,p=c),u(p)):n(p,c)}return O}var tn=oa(!0),sa=oa(!1),Yr=ht(null),Xr=null,Wt=null,du=null;function pu(){du=Wt=Xr=null}function hu(e){var t=Yr.current;$(Yr),e._currentValue=t}function Ni(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Zt(e,t){Xr=e,du=Wt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(fe=!0),e.firstContext=null)}function Ne(e){var t=e._currentValue;if(du!==e)if(e={context:e,memoizedValue:t,next:null},Wt===null){if(Xr===null)throw Error(g(308));Wt=e,Xr.dependencies={lanes:0,firstContext:e}}else Wt=Wt.next=e;return t}var St=null;function mu(e){St===null?St=[e]:St.push(e)}function aa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,mu(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ye(e,r)}function Ye(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var qe=!1;function vu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ca(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function He(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function ot(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,R&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ye(e,n)}return l=r.interleaved,l===null?(t.next=t,mu(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ye(e,n)}function Cr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,tu(e,n)}}function jo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=u:i=i.next=u,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Gr(e,t,n,r){var l=e.updateQueue;qe=!1;var i=l.firstBaseUpdate,u=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var s=o,d=s.next;s.next=null,u===null?i=d:u.next=d,u=s;var m=e.alternate;m!==null&&(m=m.updateQueue,o=m.lastBaseUpdate,o!==u&&(o===null?m.firstBaseUpdate=d:o.next=d,m.lastBaseUpdate=s))}if(i!==null){var v=l.baseState;u=0,m=d=s=null,o=i;do{var h=o.lane,w=o.eventTime;if((r&h)===h){m!==null&&(m=m.next={eventTime:w,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var x=e,S=o;switch(h=t,w=n,S.tag){case 1:if(x=S.payload,typeof x=="function"){v=x.call(w,v,h);break e}v=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=S.payload,h=typeof x=="function"?x.call(w,v,h):x,h==null)break e;v=W({},v,h);break e;case 2:qe=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[o]:h.push(o))}else w={eventTime:w,lane:h,tag:o.tag,payload:o.payload,callback:o.callback,next:null},m===null?(d=m=w,s=v):m=m.next=w,u|=h;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;h=o,o=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(m===null&&(s=v),l.baseState=s,l.firstBaseUpdate=d,l.lastBaseUpdate=m,t=l.shared.interleaved,t!==null){l=t;do u|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);zt|=u,e.lanes=u,e.memoizedState=v}}function Eo(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Hl.transition;Hl.transition={};try{e(!1),t()}finally{I=n,Hl.transition=r}}function Ca(){return Ce().memoizedState}function id(e,t,n){var r=at(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Pa(e))za(t,n);else if(n=aa(e,t,n,r),n!==null){var l=oe();Oe(n,e,r,l),Ta(n,t,r)}}function ud(e,t,n){var r=at(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Pa(e))za(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var u=t.lastRenderedState,o=i(u,n);if(l.hasEagerState=!0,l.eagerState=o,Ie(o,u)){var s=t.interleaved;s===null?(l.next=l,mu(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=aa(e,t,l,r),n!==null&&(l=oe(),Oe(n,e,r,l),Ta(n,t,r))}}function Pa(e){var t=e.alternate;return e===B||t!==null&&t===B}function za(e,t){Tn=Jr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ta(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,tu(e,n)}}var qr={readContext:Ne,useCallback:ne,useContext:ne,useEffect:ne,useImperativeHandle:ne,useInsertionEffect:ne,useLayoutEffect:ne,useMemo:ne,useReducer:ne,useRef:ne,useState:ne,useDebugValue:ne,useDeferredValue:ne,useTransition:ne,useMutableSource:ne,useSyncExternalStore:ne,useId:ne,unstable_isNewReconciler:!1},od={readContext:Ne,useCallback:function(e,t){return Me().memoizedState=[e,t===void 0?null:t],e},useContext:Ne,useEffect:No,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,zr(4194308,4,ka.bind(null,t,e),n)},useLayoutEffect:function(e,t){return zr(4194308,4,e,t)},useInsertionEffect:function(e,t){return zr(4,2,e,t)},useMemo:function(e,t){var n=Me();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Me();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=id.bind(null,B,e),[r.memoizedState,e]},useRef:function(e){var t=Me();return e={current:e},t.memoizedState=e},useState:_o,useDebugValue:Eu,useDeferredValue:function(e){return Me().memoizedState=e},useTransition:function(){var e=_o(!1),t=e[0];return e=ld.bind(null,e[1]),Me().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=B,l=Me();if(A){if(n===void 0)throw Error(g(407));n=n()}else{if(n=t(),q===null)throw Error(g(349));Pt&30||ha(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,No(va.bind(null,r,i,e),[e]),r.flags|=2048,Gn(9,ma.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=Me(),t=q.identifierPrefix;if(A){var n=We,r=Be;n=(r&~(1<<32-Re(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Yn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[Fe]=t,e[Hn]=r,Aa(e,t,!1,!1),t.stateNode=e;e:{switch(u=si(n,r),n){case"dialog":U("cancel",e),U("close",e),l=r;break;case"iframe":case"object":case"embed":U("load",e),l=r;break;case"video":case"audio":for(l=0;lln&&(t.flags|=128,r=!0,wn(i,!1),t.lanes=4194304)}else{if(!r)if(e=Zr(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),wn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!u.alternate&&!A)return re(t),null}else 2*K()-i.renderingStartTime>ln&&n!==1073741824&&(t.flags|=128,r=!0,wn(i,!1),t.lanes=4194304);i.isBackwards?(u.sibling=t.child,t.child=u):(n=i.last,n!==null?n.sibling=u:t.child=u,i.last=u)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=K(),t.sibling=null,n=V.current,F(V,r?n&1|2:n&1),t):(re(t),null);case 22:case 23:return Tu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ve&1073741824&&(re(t),t.subtreeFlags&6&&(t.flags|=8192)):re(t),null;case 24:return null;case 25:return null}throw Error(g(156,t.tag))}function md(e,t){switch(cu(t),t.tag){case 1:return pe(t.type)&&Wr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return nn(),$(de),$(ie),wu(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return gu(t),null;case 13:if($(V),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(g(340));en()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return $(V),null;case 4:return nn(),null;case 10:return hu(t.type._context),null;case 22:case 23:return Tu(),null;case 24:return null;default:return null}}var gr=!1,le=!1,vd=typeof WeakSet=="function"?WeakSet:Set,j=null;function Ht(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){H(e,t,r)}else n.current=null}function Di(e,t,n){try{n()}catch(r){H(e,t,r)}}var Fo=!1;function yd(e,t){if(gi=$r,e=Ys(),su(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var u=0,o=-1,s=-1,d=0,m=0,v=e,h=null;t:for(;;){for(var w;v!==n||l!==0&&v.nodeType!==3||(o=u+l),v!==i||r!==0&&v.nodeType!==3||(s=u+r),v.nodeType===3&&(u+=v.nodeValue.length),(w=v.firstChild)!==null;)h=v,v=w;for(;;){if(v===e)break t;if(h===n&&++d===l&&(o=u),h===i&&++m===r&&(s=u),(w=v.nextSibling)!==null)break;v=h,h=v.parentNode}v=w}n=o===-1||s===-1?null:{start:o,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(wi={focusedElem:e,selectionRange:n},$r=!1,j=t;j!==null;)if(t=j,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,j=e;else for(;j!==null;){t=j;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var S=x.memoizedProps,O=x.memoizedState,p=t.stateNode,c=p.getSnapshotBeforeUpdate(t.elementType===t.type?S:ze(t.type,S),O);p.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var f=t.stateNode.containerInfo;f.nodeType===1?f.textContent="":f.nodeType===9&&f.documentElement&&f.removeChild(f.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(g(163))}}catch(y){H(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,j=e;break}j=t.return}return x=Fo,Fo=!1,x}function Ln(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Di(t,n,i)}l=l.next}while(l!==r)}}function hl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Mi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Wa(e){var t=e.alternate;t!==null&&(e.alternate=null,Wa(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Fe],delete t[Hn],delete t[ki],delete t[bf],delete t[ed])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Ha(e){return e.tag===5||e.tag===3||e.tag===4}function Uo(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Ha(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Fi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Br));else if(r!==4&&(e=e.child,e!==null))for(Fi(e,t,n),e=e.sibling;e!==null;)Fi(e,t,n),e=e.sibling}function Ui(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ui(e,t,n),e=e.sibling;e!==null;)Ui(e,t,n),e=e.sibling}var b=null,Te=!1;function Ze(e,t,n){for(n=n.child;n!==null;)Qa(e,t,n),n=n.sibling}function Qa(e,t,n){if(Ue&&typeof Ue.onCommitFiberUnmount=="function")try{Ue.onCommitFiberUnmount(ul,n)}catch{}switch(n.tag){case 5:le||Ht(n,t);case 6:var r=b,l=Te;b=null,Ze(e,t,n),b=r,Te=l,b!==null&&(Te?(e=b,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):b.removeChild(n.stateNode));break;case 18:b!==null&&(Te?(e=b,n=n.stateNode,e.nodeType===8?Vl(e.parentNode,n):e.nodeType===1&&Vl(e,n),$n(e)):Vl(b,n.stateNode));break;case 4:r=b,l=Te,b=n.stateNode.containerInfo,Te=!0,Ze(e,t,n),b=r,Te=l;break;case 0:case 11:case 14:case 15:if(!le&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,u=i.destroy;i=i.tag,u!==void 0&&(i&2||i&4)&&Di(n,t,u),l=l.next}while(l!==r)}Ze(e,t,n);break;case 1:if(!le&&(Ht(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){H(n,t,o)}Ze(e,t,n);break;case 21:Ze(e,t,n);break;case 22:n.mode&1?(le=(r=le)||n.memoizedState!==null,Ze(e,t,n),le=r):Ze(e,t,n);break;default:Ze(e,t,n)}}function $o(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new vd),t.forEach(function(r){var l=Nd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Pe(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=u),r&=~i}if(r=l,r=K()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*wd(r/1960))-r,10e?16:e,nt===null)var r=!1;else{if(e=nt,nt=null,tl=0,R&6)throw Error(g(331));var l=R;for(R|=4,j=e.current;j!==null;){var i=j,u=i.child;if(j.flags&16){var o=i.deletions;if(o!==null){for(var s=0;sK()-Pu?Et(e,0):Cu|=n),he(e,t)}function ba(e,t){t===0&&(e.mode&1?(t=ar,ar<<=1,!(ar&130023424)&&(ar=4194304)):t=1);var n=oe();e=Ye(e,t),e!==null&&(qn(e,t,n),he(e,n))}function _d(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ba(e,n)}function Nd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(g(314))}r!==null&&r.delete(t),ba(e,n)}var ec;ec=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||de.current)fe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return fe=!1,pd(e,t,n);fe=!!(e.flags&131072)}else fe=!1,A&&t.flags&1048576&&la(t,Kr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Tr(e,t),e=t.pendingProps;var l=bt(t,ie.current);Zt(t,n),l=Su(null,t,r,e,l,n);var i=ku();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,pe(r)?(i=!0,Hr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,vu(t),l.updater=pl,t.stateNode=l,l._reactInternals=t,Pi(t,r,e,n),t=Li(null,t,r,!0,i,n)):(t.tag=0,A&&i&&au(t),ue(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Tr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Pd(r),e=ze(r,e),l){case 0:t=Ti(null,t,r,e,n);break e;case 1:t=Io(null,t,r,e,n);break e;case 11:t=Ro(null,t,r,e,n);break e;case 14:t=Oo(null,t,r,ze(r.type,e),n);break e}throw Error(g(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Ti(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Io(e,t,r,l,n);case 3:e:{if(Fa(t),e===null)throw Error(g(387));r=t.pendingProps,i=t.memoizedState,l=i.element,ca(e,t),Gr(t,r,null,n);var u=t.memoizedState;if(r=u.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=rn(Error(g(423)),t),t=Do(e,t,r,n,l);break e}else if(r!==l){l=rn(Error(g(424)),t),t=Do(e,t,r,n,l);break e}else for(ye=ut(t.stateNode.containerInfo.firstChild),ge=t,A=!0,Le=null,n=sa(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(en(),r===l){t=Xe(e,t,n);break e}ue(e,t,r,n)}t=t.child}return t;case 5:return fa(t),e===null&&_i(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,u=l.children,xi(r,l)?u=null:i!==null&&xi(r,i)&&(t.flags|=32),Ma(e,t),ue(e,t,u,n),t.child;case 6:return e===null&&_i(t),null;case 13:return Ua(e,t,n);case 4:return yu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=tn(t,null,r,n):ue(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Ro(e,t,r,l,n);case 7:return ue(e,t,t.pendingProps,n),t.child;case 8:return ue(e,t,t.pendingProps.children,n),t.child;case 12:return ue(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,u=l.value,F(Yr,r._currentValue),r._currentValue=u,i!==null)if(Ie(i.value,u)){if(i.children===l.children&&!de.current){t=Xe(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var o=i.dependencies;if(o!==null){u=i.child;for(var s=o.firstContext;s!==null;){if(s.context===r){if(i.tag===1){s=He(-1,n&-n),s.tag=2;var d=i.updateQueue;if(d!==null){d=d.shared;var m=d.pending;m===null?s.next=s:(s.next=m.next,m.next=s),d.pending=s}}i.lanes|=n,s=i.alternate,s!==null&&(s.lanes|=n),Ni(i.return,n,t),o.lanes|=n;break}s=s.next}}else if(i.tag===10)u=i.type===t.type?null:i.child;else if(i.tag===18){if(u=i.return,u===null)throw Error(g(341));u.lanes|=n,o=u.alternate,o!==null&&(o.lanes|=n),Ni(u,n,t),u=i.sibling}else u=i.child;if(u!==null)u.return=i;else for(u=i;u!==null;){if(u===t){u=null;break}if(i=u.sibling,i!==null){i.return=u.return,u=i;break}u=u.return}i=u}ue(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Zt(t,n),l=Ne(l),r=r(l),t.flags|=1,ue(e,t,r,n),t.child;case 14:return r=t.type,l=ze(r,t.pendingProps),l=ze(r.type,l),Oo(e,t,r,l,n);case 15:return Ia(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Tr(e,t),t.tag=1,pe(r)?(e=!0,Hr(t)):e=!1,Zt(t,n),La(t,r,l),Pi(t,r,l,n),Li(null,t,r,!0,e,n);case 19:return $a(e,t,n);case 22:return Da(e,t,n)}throw Error(g(156,t.tag))};function tc(e,t){return Ps(e,t)}function Cd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ee(e,t,n,r){return new Cd(e,t,n,r)}function Ru(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Pd(e){if(typeof e=="function")return Ru(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ji)return 11;if(e===qi)return 14}return 2}function ct(e,t){var n=e.alternate;return n===null?(n=Ee(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Or(e,t,n,r,l,i){var u=2;if(r=e,typeof e=="function")Ru(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Dt:return _t(n.children,l,i,t);case Zi:u=8,l|=8;break;case ql:return e=Ee(12,n,t,l|2),e.elementType=ql,e.lanes=i,e;case bl:return e=Ee(13,n,t,l),e.elementType=bl,e.lanes=i,e;case ei:return e=Ee(19,n,t,l),e.elementType=ei,e.lanes=i,e;case fs:return vl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case as:u=10;break e;case cs:u=9;break e;case Ji:u=11;break e;case qi:u=14;break e;case Je:u=16,r=null;break e}throw Error(g(130,e==null?e:typeof e,""))}return t=Ee(u,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function _t(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function vl(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=fs,e.lanes=n,e.stateNode={isHidden:!1},e}function Gl(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function Zl(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function zd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Tl(0),this.expirationTimes=Tl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Tl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Ou(e,t,n,r,l,i,u,o,s){return e=new zd(e,t,n,o,s),t===1?(t=1,i===!0&&(t|=8)):t=0,i=Ee(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},vu(i),e}function Td(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(ic)}catch(e){console.error(e)}}ic(),is.exports=xe;var Dd=is.exports,uc,Yo=Dd;uc=Yo.createRoot,Yo.hydrateRoot;const oc=[{id:"overview",label:"总览",icon:"⌂"},{id:"nodes",label:"节点",icon:"◈"},{id:"tasks",label:"任务",icon:"↗"},{id:"events",label:"白名单事件",icon:"◉"},{id:"audit",label:"操作审计",icon:"≡"}];class Md extends Error{constructor(t,n){super(t),this.status=n}}async function jt(e,{token:t,method:n="GET",body:r}={}){var o;const l={};t&&(l.Authorization=`Bearer ${t}`),r!==void 0&&(l["Content-Type"]="application/json");const i=await fetch(e,{method:n,headers:l,body:r===void 0?void 0:JSON.stringify(r)}),u=await i.json().catch(()=>({}));if(!i.ok)throw new Md(((o=u.error)==null?void 0:o.message)||`请求失败 (${i.status})`,i.status);return u}function un(e){if(!e)return"—";const t=new Date(e);return Number.isNaN(t.getTime())?"—":t.toLocaleString("zh-CN",{hour12:!1})}function ll(e,t=12){return e?e.length>t?`${e.slice(0,t)}…`:e:"—"}function Fd(e){return["Online","Succeeded","Accepted"].includes(e)?"positive":["Running","Pending","Degraded"].includes(e)?"warning":["Failed","Cancelled","Expired","ResultUnconfirmed","Offline","SessionLocked","WechatNotRunning","WechatNotLoggedIn"].includes(e)?"negative":"neutral"}function Sl({value:e}){return a.jsxs("span",{className:`status-badge ${Fd(e)}`,children:[a.jsx("i",{}),e||"Unknown"]})}function Sr({label:e,value:t,detail:n,tone:r="blue"}){return a.jsxs("div",{className:`metric-card ${r}`,children:[a.jsx("div",{className:"metric-label",children:e}),a.jsx("div",{className:"metric-value",children:t}),a.jsx("div",{className:"metric-detail",children:n})]})}function Ud({onLogin:e}){const[t,n]=D.useState(""),[r,l]=D.useState(""),[i,u]=D.useState(""),[o,s]=D.useState(!1),d=async m=>{m.preventDefault(),u(""),s(!0);try{const v=await jt("/v1/auth/login",{method:"POST",body:{username:t,password:r}});e(v.access_token,t)}catch(v){u(v.message)}finally{s(!1)}};return a.jsx("main",{className:"login-page",children:a.jsxs("div",{className:"login-panel",children:[a.jsx("div",{className:"brand-mark",children:"W"}),a.jsx("div",{className:"eyebrow",children:"WXAGENT CONTROL PLANE"}),a.jsx("h1",{children:"欢迎回来"}),a.jsx("p",{className:"muted",children:"登录远程节点控制中心,查看连接状态并管理已授权任务。"}),a.jsxs("form",{onSubmit:d,className:"login-form",children:[a.jsxs("label",{children:["用户名",a.jsx("input",{autoFocus:!0,value:t,onChange:m=>n(m.target.value),autoComplete:"username"})]}),a.jsxs("label",{children:["密码",a.jsx("input",{type:"password",value:r,onChange:m=>l(m.target.value),autoComplete:"current-password"})]}),i&&a.jsx("div",{className:"form-error",children:i}),a.jsx("button",{className:"primary-button full",disabled:o||!t||!r,children:o?"登录中…":"登录控制面"})]}),a.jsxs("div",{className:"login-footnote",children:[a.jsx("span",{className:"secure-dot"})," 会话仅保存在当前浏览器标签页"]})]})})}function $d({view:e,setView:t,onLogout:n,username:r}){return a.jsxs("aside",{className:"sidebar",children:[a.jsxs("div",{className:"sidebar-brand",children:[a.jsx("div",{className:"brand-mark small",children:"W"}),a.jsxs("div",{children:[a.jsx("strong",{children:"WxAgent"}),a.jsx("span",{children:"Control Plane"})]})]}),a.jsx("div",{className:"nav-caption",children:"工作台"}),a.jsx("nav",{children:oc.map(l=>a.jsxs("button",{className:e===l.id?"nav-item active":"nav-item",onClick:()=>t(l.id),children:[a.jsx("span",{className:"nav-icon",children:l.icon}),l.label]},l.id))}),a.jsx("div",{className:"sidebar-spacer"}),a.jsxs("div",{className:"connection-hint",children:[a.jsx("span",{className:"secure-dot"}),a.jsxs("div",{children:[a.jsx("strong",{children:"安全连接"}),a.jsx("small",{children:"控制面 API 已认证"})]})]}),a.jsxs("div",{className:"user-menu",children:[a.jsx("div",{className:"avatar",children:(r||"A").slice(0,1).toUpperCase()}),a.jsxs("div",{className:"user-name",children:[a.jsx("strong",{children:r||"管理员"}),a.jsx("small",{children:"管理员"})]}),a.jsx("button",{title:"退出登录",onClick:n,children:"↪"})]})]})}function Ad({title:e,subtitle:t,onRefresh:n,loading:r}){return a.jsxs("header",{className:"topbar",children:[a.jsxs("div",{children:[a.jsxs("div",{className:"eyebrow",children:["CONTROL PLANE / ",e.toUpperCase()]}),a.jsx("h2",{children:e}),a.jsx("p",{children:t})]}),a.jsxs("button",{className:"refresh-button",onClick:n,disabled:r,children:[a.jsx("span",{className:r?"spin":"",children:"↻"}),r?"同步中":"刷新数据"]})]})}function Vd({nodes:e,tasks:t,events:n,onView:r}){const l=e.filter(s=>s.status==="Online").length,i=t.filter(s=>["Pending","Accepted","Running"].includes(s.status)).length,u=t.filter(s=>s.status==="ResultUnconfirmed").length,o=n.slice(0,5);return a.jsxs(a.Fragment,{children:[a.jsxs("div",{className:"metric-grid",children:[a.jsx(Sr,{label:"在线节点",value:`${l}/${e.length}`,detail:l?"节点连接正常":"等待节点连接",tone:"blue"}),a.jsx(Sr,{label:"执行中任务",value:i,detail:i?"需要持续关注":"当前无执行中任务",tone:"purple"}),a.jsx(Sr,{label:"已接收事件",value:n.length,detail:"仅包含白名单会话数据",tone:"green"}),a.jsx(Sr,{label:"待人工核对",value:u,detail:u?"禁止自动重放":"暂无不确定结果",tone:u?"orange":"slate"})]}),a.jsxs("div",{className:"content-grid two-col",children:[a.jsxs("section",{className:"panel",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsxs("div",{children:[a.jsx("h3",{children:"节点健康"}),a.jsx("p",{children:"最近心跳与微信会话状态"})]}),a.jsx("button",{className:"text-button",onClick:()=>r("nodes"),children:"查看全部 →"})]}),e.length===0?a.jsx(on,{text:"暂无已注册节点"}):a.jsx("div",{className:"compact-list",children:e.slice(0,5).map(s=>a.jsxs("div",{className:"compact-row",children:[a.jsx("div",{className:"node-avatar",children:s.node_id.slice(0,1).toUpperCase()}),a.jsxs("div",{className:"row-main",children:[a.jsx("strong",{children:s.node_id}),a.jsxs("span",{children:[s.wechat_logged_in?"微信已登录":"微信未登录"," ·"," ",un(s.last_heartbeat_at)]})]}),a.jsx(Sl,{value:s.status})]},s.node_id))})]}),a.jsxs("section",{className:"panel",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsxs("div",{children:[a.jsx("h3",{children:"最近白名单事件"}),a.jsx("p",{children:"中心已持久化的数据"})]}),a.jsx("button",{className:"text-button",onClick:()=>r("events"),children:"查看全部 →"})]}),o.length===0?a.jsx(on,{text:"暂无白名单事件"}):a.jsx("div",{className:"compact-list",children:o.map(s=>a.jsxs("div",{className:"compact-row",children:[a.jsx("div",{className:"event-icon",children:"◉"}),a.jsxs("div",{className:"row-main",children:[a.jsx("strong",{children:ll(s.chat_id)}),a.jsxs("span",{children:[s.chat_type," · ",un(s.received_at)]})]}),a.jsx("span",{className:"event-preview",children:s.content||"无正文"})]},s.event_id))})]})]})]})}function Bd({nodes:e}){return a.jsxs("section",{className:"panel full-panel",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsxs("div",{children:[a.jsx("h3",{children:"节点管理"}),a.jsx("p",{children:"节点主动出站连接,状态来自最近一次心跳。"})]}),a.jsxs("span",{className:"table-count",children:[e.length," 个节点"]})]}),e.length===0?a.jsx(on,{text:"暂无节点,请先启动已配置认证信息的 Desktop Agent。"}):a.jsx("div",{className:"table-wrap",children:a.jsxs("table",{children:[a.jsx("thead",{children:a.jsxs("tr",{children:[a.jsx("th",{children:"节点"}),a.jsx("th",{children:"状态"}),a.jsx("th",{children:"微信状态"}),a.jsx("th",{children:"活动账号"}),a.jsx("th",{children:"能力"}),a.jsx("th",{children:"最近心跳"}),a.jsx("th",{children:"上报配置"})]})}),a.jsx("tbody",{children:e.map(t=>{var n;return a.jsxs("tr",{children:[a.jsx("td",{children:a.jsxs("div",{className:"cell-primary",children:[a.jsx("div",{className:"node-avatar",children:t.node_id.slice(0,1).toUpperCase()}),a.jsxs("div",{children:[a.jsx("strong",{children:t.node_id}),a.jsxs("small",{children:[t.agent_version||"—"," ·"," ",t.protocol_version||"—"]})]})]})}),a.jsx("td",{children:a.jsx(Sl,{value:t.status})}),a.jsxs("td",{children:[a.jsx("span",{className:t.wechat_logged_in?"inline-positive":"inline-muted",children:t.wechat_running?t.wechat_logged_in?"已登录":"未登录":"未运行"}),t.session_locked&&a.jsx("small",{className:"cell-note",children:"桌面已锁定"})]}),a.jsx("td",{children:t.active_account_id||"—"}),a.jsxs("td",{children:[a.jsx("span",{className:"capability-count",children:((n=t.capabilities)==null?void 0:n.length)||0})," ","项"]}),a.jsx("td",{children:un(t.last_heartbeat_at)}),a.jsxs("td",{children:["v",t.reporting_config_version??0]})]},t.node_id)})})]})})]})}function Wd({nodes:e,tasks:t,token:n,onChanged:r}){const[l,i]=D.useState(!1),[u,o]=D.useState(""),[s,d]=D.useState("all"),m=t.filter(h=>s==="all"||h.status===s),v=async h=>{o(h.task_id);try{await jt(`/v1/tasks/${encodeURIComponent(h.task_id)}/cancel`,{token:n,method:"POST"}),r()}catch(w){window.alert(w.message)}finally{o("")}};return a.jsxs(a.Fragment,{children:[a.jsxs("section",{className:"panel full-panel",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsxs("div",{children:[a.jsx("h3",{children:"任务队列"}),a.jsx("p",{children:"任务按节点与账号路由,取消请求需等待节点确认。"})]}),a.jsxs("div",{className:"heading-actions",children:[a.jsxs("select",{value:s,onChange:h=>d(h.target.value),children:[a.jsx("option",{value:"all",children:"全部状态"}),a.jsx("option",{value:"Pending",children:"待处理"}),a.jsx("option",{value:"Accepted",children:"已接收"}),a.jsx("option",{value:"Running",children:"执行中"}),a.jsx("option",{value:"Succeeded",children:"成功"}),a.jsx("option",{value:"Failed",children:"失败"}),a.jsx("option",{value:"Cancelled",children:"已取消"}),a.jsx("option",{value:"ResultUnconfirmed",children:"待核对"})]}),a.jsx("button",{className:"primary-button",onClick:()=>i(!0),children:"+ 创建任务"})]})]}),m.length===0?a.jsx(on,{text:"暂无符合条件的任务"}):a.jsx("div",{className:"table-wrap",children:a.jsxs("table",{children:[a.jsx("thead",{children:a.jsxs("tr",{children:[a.jsx("th",{children:"任务"}),a.jsx("th",{children:"路由"}),a.jsx("th",{children:"状态"}),a.jsx("th",{children:"租约"}),a.jsx("th",{children:"更新时间"}),a.jsx("th",{children:"结果"}),a.jsx("th",{})]})}),a.jsx("tbody",{children:m.map(h=>{var w,x;return a.jsxs("tr",{children:[a.jsx("td",{children:a.jsxs("div",{className:"cell-primary task-id",children:[a.jsx("span",{className:"task-symbol",children:"↗"}),a.jsxs("div",{children:[a.jsx("strong",{children:ll(h.task_id,16)}),a.jsxs("small",{children:[h.kind," · ",ll(h.idempotency_key,16)]})]})]})}),a.jsxs("td",{children:[a.jsx("strong",{children:h.node_id}),a.jsxs("small",{className:"cell-note",children:["账号 ",h.account_id]})]}),a.jsxs("td",{children:[a.jsx(Sl,{value:h.status}),h.cancel_requested_at&&!["Succeeded","Failed","Cancelled","Expired","ResultUnconfirmed"].includes(h.status)&&a.jsx("small",{className:"cell-note cancel-note",children:"取消待确认"})]}),a.jsx("td",{children:h.lease_generation?`第 ${h.lease_generation} 代`:"未分配"}),a.jsx("td",{children:un(h.updated_at)}),a.jsx("td",{children:(w=h.result)!=null&&w.error_code?a.jsx("span",{className:"error-code",children:h.result.error_code}):((x=h.result)==null?void 0:x.message)||"—"}),a.jsx("td",{children:!["Succeeded","Failed","Cancelled","Expired","ResultUnconfirmed"].includes(h.status)&&a.jsx("button",{className:"danger-button",disabled:u===h.task_id,onClick:()=>v(h),children:u===h.task_id?"…":"取消"})})]},h.task_id)})})]})})]}),l&&a.jsx(Hd,{nodes:e,token:n,onClose:()=>i(!1),onCreated:()=>{i(!1),r()}})]})}function Hd({nodes:e,token:t,onClose:n,onCreated:r}){var p,c;const[l,i]=D.useState(((p=e[0])==null?void 0:p.node_id)||""),[u,o]=D.useState(((c=e[0])==null?void 0:c.active_account_id)||""),[s,d]=D.useState(""),[m,v]=D.useState(""),[h,w]=D.useState(""),[x,S]=D.useState(!1),O=async f=>{f.preventDefault(),S(!0),w("");try{await jt("/v1/tasks",{token:t,method:"POST",body:{node_id:l,account_id:u,kind:"send-text",idempotency_key:crypto.randomUUID(),payload:{target_id:s,text:m,confirmed:!0}}}),r()}catch(y){w(y.message)}finally{S(!1)}};return a.jsx("div",{className:"modal-backdrop",onMouseDown:f=>f.target===f.currentTarget&&n(),children:a.jsxs("div",{className:"modal",children:[a.jsxs("div",{className:"modal-heading",children:[a.jsxs("div",{children:[a.jsx("div",{className:"eyebrow",children:"NEW REMOTE TASK"}),a.jsx("h3",{children:"发送文本消息"}),a.jsx("p",{children:"仅允许经过节点命令白名单校验的 send-text 操作。"})]}),a.jsx("button",{className:"close-button",onClick:n,children:"×"})]}),a.jsxs("form",{onSubmit:O,className:"task-form",children:[a.jsxs("label",{children:["目标节点",a.jsx("select",{value:l,onChange:f=>{i(f.target.value);const y=e.find(k=>k.node_id===f.target.value);y!=null&&y.active_account_id&&o(y.active_account_id)},children:e.map(f=>a.jsx("option",{value:f.node_id,children:f.node_id},f.node_id))})]}),a.jsxs("label",{children:["账号 ID",a.jsx("input",{value:u,onChange:f=>o(f.target.value),placeholder:"与节点当前活动账号一致"})]}),a.jsxs("label",{children:["目标会话 ID",a.jsx("input",{value:s,onChange:f=>d(f.target.value),placeholder:"已确认的稳定会话 ID"})]}),a.jsxs("label",{children:["消息内容",a.jsx("textarea",{value:m,onChange:f=>v(f.target.value),maxLength:4e3,rows:5,placeholder:"输入要发送的文本"})]}),h&&a.jsx("div",{className:"form-error",children:h}),a.jsxs("div",{className:"modal-actions",children:[a.jsx("button",{type:"button",className:"secondary-button",onClick:n,children:"返回"}),a.jsx("button",{className:"primary-button",disabled:x||!l||!u||!s||!m,children:x?"提交中…":"提交任务"})]})]})]})})}function Qd({events:e}){const[t,n]=D.useState(""),r=e.filter(l=>!t||[l.node_id,l.account_id,l.chat_id,l.content].some(i=>String(i||"").toLowerCase().includes(t.toLowerCase())));return a.jsxs("section",{className:"panel full-panel",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsxs("div",{children:[a.jsx("h3",{children:"白名单事件"}),a.jsx("p",{children:"只展示中心已接收且通过节点本地授权的事件。"})]}),a.jsx("input",{className:"search-input",value:t,onChange:l=>n(l.target.value),placeholder:"搜索节点、账号或会话 ID"})]}),r.length===0?a.jsx(on,{text:"暂无已授权事件"}):a.jsx("div",{className:"table-wrap",children:a.jsxs("table",{children:[a.jsx("thead",{children:a.jsxs("tr",{children:[a.jsx("th",{children:"接收时间"}),a.jsx("th",{children:"节点 / 账号"}),a.jsx("th",{children:"会话"}),a.jsx("th",{children:"类型"}),a.jsx("th",{children:"序号"}),a.jsx("th",{children:"内容"}),a.jsx("th",{children:"授权版本"})]})}),a.jsx("tbody",{children:r.map(l=>a.jsxs("tr",{children:[a.jsx("td",{children:un(l.received_at)}),a.jsxs("td",{children:[a.jsx("strong",{children:l.node_id}),a.jsx("small",{className:"cell-note",children:l.account_id})]}),a.jsxs("td",{children:[a.jsx("strong",{children:ll(l.chat_id,20)}),a.jsx("small",{className:"cell-note",children:l.chat_type})]}),a.jsx("td",{children:l.event_type}),a.jsx("td",{children:a.jsxs("span",{className:"seq-pill",children:["#",l.event_seq]})}),a.jsx("td",{children:a.jsx("span",{className:"event-content",children:l.content||"无正文"})}),a.jsxs("td",{children:["v",l.authorization_version]})]},l.event_id))})]})})]})}function Kd({audit:e}){return a.jsxs("section",{className:"panel full-panel",children:[a.jsxs("div",{className:"panel-heading",children:[a.jsxs("div",{children:[a.jsx("h3",{children:"操作审计"}),a.jsx("p",{children:"节点连接、任务操作与事件持久化的审计轨迹。"})]}),a.jsxs("span",{className:"table-count",children:["最近 ",e.length," 条"]})]}),e.length===0?a.jsx(on,{text:"暂无审计记录"}):a.jsx("div",{className:"timeline",children:e.map(t=>a.jsxs("div",{className:"timeline-row",children:[a.jsx("div",{className:"timeline-dot"}),a.jsxs("div",{className:"timeline-main",children:[a.jsx("strong",{children:t.action}),a.jsxs("span",{children:[t.principal," · ",t.resource]})]}),a.jsxs("div",{className:"timeline-meta",children:[a.jsx(Sl,{value:t.outcome==="success"?"Succeeded":t.outcome}),a.jsx("span",{children:un(t.at)})]})]},t.id))})]})}function on({text:e}){return a.jsxs("div",{className:"empty-state",children:[a.jsx("div",{className:"empty-icon",children:"◌"}),a.jsx("p",{children:e})]})}function Yd(){var N;const[e,t]=D.useState(()=>sessionStorage.getItem("wxagent-token")||""),[n,r]=D.useState(()=>sessionStorage.getItem("wxagent-user")||""),[l,i]=D.useState("overview"),[u,o]=D.useState([]),[s,d]=D.useState([]),[m,v]=D.useState([]),[h,w]=D.useState([]),[x,S]=D.useState(!1),[O,p]=D.useState(""),c=D.useCallback(()=>{sessionStorage.removeItem("wxagent-token"),sessionStorage.removeItem("wxagent-user"),t(""),r("")},[]),f=D.useCallback(async()=>{if(e){S(!0),p("");try{const[E,M,T,me]=await Promise.all([jt("/v1/nodes",{token:e}),jt("/v1/tasks?limit=200",{token:e}),jt("/v1/events?limit=200",{token:e}),jt("/v1/audit?limit=200",{token:e})]);o(E.nodes||[]),d(M.tasks||[]),v(T.events||[]),w(me.audit||[])}catch(E){E.status===401?c():p(E.message)}finally{S(!1)}}},[e,c]);D.useEffect(()=>{if(f(),!e)return;const E=window.setInterval(f,15e3);return()=>window.clearInterval(E)},[f,e]);const y=(E,M)=>{sessionStorage.setItem("wxagent-token",E),sessionStorage.setItem("wxagent-user",M),t(E),r(M)},k=((N=oc.find(E=>E.id===l))==null?void 0:N.label)||"总览",C={overview:"跨节点连接、任务与已授权数据的安全视图",nodes:"查看节点心跳、微信登录状态与能力摘要",tasks:"管理远程任务,所有执行均由节点本地能力边界约束",events:"按账号与会话作用域查看已授权上报内容",audit:"追踪控制面与节点之间的关键操作"}[l];return e?a.jsxs("div",{className:"app-shell",children:[a.jsx($d,{view:l,setView:i,onLogout:c,username:n}),a.jsxs("main",{className:"main-area",children:[a.jsx(Ad,{title:k,subtitle:C,onRefresh:f,loading:x}),O&&a.jsxs("div",{className:"global-error",children:[a.jsx("span",{children:"!"}),O,a.jsx("button",{onClick:()=>p(""),children:"×"})]}),a.jsxs("div",{className:"page-content",children:[l==="overview"&&a.jsx(Vd,{nodes:u,tasks:s,events:m,onView:i}),l==="nodes"&&a.jsx(Bd,{nodes:u}),l==="tasks"&&a.jsx(Wd,{nodes:u,tasks:s,token:e,onChanged:f}),l==="events"&&a.jsx(Qd,{events:m}),l==="audit"&&a.jsx(Kd,{audit:h})]}),a.jsxs("footer",{className:"footer",children:[a.jsx("span",{children:"WxAgent Control Plane · Protocol v1"}),a.jsx("span",{children:"数据上报遵循节点本地白名单"})]})]})]}):a.jsx(Ud,{onLogin:y})}uc(document.getElementById("root")).render(a.jsx(Yd,{})); diff --git a/control-plane/web/dist/index.html b/control-plane/web/dist/index.html new file mode 100644 index 0000000..e9d26e6 --- /dev/null +++ b/control-plane/web/dist/index.html @@ -0,0 +1,14 @@ + + + + + + + WxAgent 控制面 + + + + +
+ + diff --git a/control-plane/web/embed.go b/control-plane/web/embed.go new file mode 100644 index 0000000..f5765ec --- /dev/null +++ b/control-plane/web/embed.go @@ -0,0 +1,8 @@ +package web + +import "embed" + +// Dist contains the React production build embedded into the control-plane binary. +// +//go:embed dist/* +var Dist embed.FS diff --git a/control-plane/web/index.html b/control-plane/web/index.html new file mode 100644 index 0000000..54c30c8 --- /dev/null +++ b/control-plane/web/index.html @@ -0,0 +1,13 @@ + + + + + + + WxAgent 控制面 + + +
+ + + diff --git a/control-plane/web/package-lock.json b/control-plane/web/package-lock.json new file mode 100644 index 0000000..d440696 --- /dev/null +++ b/control-plane/web/package-lock.json @@ -0,0 +1,1698 @@ +{ + "name": "wxagent-control-plane-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wxagent-control-plane-web", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.14" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/control-plane/web/package.json b/control-plane/web/package.json new file mode 100644 index 0000000..2f4b1ad --- /dev/null +++ b/control-plane/web/package.json @@ -0,0 +1,19 @@ +{ + "name": "wxagent-control-plane-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.14" + } +} diff --git a/control-plane/web/src/main.jsx b/control-plane/web/src/main.jsx new file mode 100644 index 0000000..c885a08 --- /dev/null +++ b/control-plane/web/src/main.jsx @@ -0,0 +1,914 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import "./styles.css"; + +const navItems = [ + { id: "overview", label: "总览", icon: "⌂" }, + { id: "nodes", label: "节点", icon: "◈" }, + { id: "tasks", label: "任务", icon: "↗" }, + { id: "events", label: "白名单事件", icon: "◉" }, + { id: "audit", label: "操作审计", icon: "≡" }, +]; + +class ApiError extends Error { + constructor(message, status) { + super(message); + this.status = status; + } +} + +async function api(path, { token, method = "GET", body } = {}) { + const headers = {}; + if (token) headers.Authorization = `Bearer ${token}`; + if (body !== undefined) headers["Content-Type"] = "application/json"; + const response = await fetch(path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) + throw new ApiError( + payload.error?.message || `请求失败 (${response.status})`, + response.status, + ); + return payload; +} + +function formatTime(value) { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? "—" + : date.toLocaleString("zh-CN", { hour12: false }); +} + +function shortId(value, length = 12) { + if (!value) return "—"; + return value.length > length ? `${value.slice(0, length)}…` : value; +} + +function statusTone(status) { + if (["Online", "Succeeded", "Accepted"].includes(status)) return "positive"; + if (["Running", "Pending", "Degraded"].includes(status)) return "warning"; + if ( + [ + "Failed", + "Cancelled", + "Expired", + "ResultUnconfirmed", + "Offline", + "SessionLocked", + "WechatNotRunning", + "WechatNotLoggedIn", + ].includes(status) + ) + return "negative"; + return "neutral"; +} + +function StatusBadge({ value }) { + return ( + + + {value || "Unknown"} + + ); +} + +function Metric({ label, value, detail, tone = "blue" }) { + return ( +
+
{label}
+
{value}
+
{detail}
+
+ ); +} + +function Login({ onLogin }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const submit = async (event) => { + event.preventDefault(); + setError(""); + setBusy(true); + try { + const result = await api("/v1/auth/login", { + method: "POST", + body: { username, password }, + }); + onLogin(result.access_token, username); + } catch (reason) { + setError(reason.message); + } finally { + setBusy(false); + } + }; + return ( +
+
+
W
+
WXAGENT CONTROL PLANE
+

欢迎回来

+

+ 登录远程节点控制中心,查看连接状态并管理已授权任务。 +

+
+ + + {error &&
{error}
} + +
+
+ 会话仅保存在当前浏览器标签页 +
+
+
+ ); +} + +function Sidebar({ view, setView, onLogout, username }) { + return ( + + ); +} + +function Topbar({ title, subtitle, onRefresh, loading }) { + return ( +
+
+
CONTROL PLANE / {title.toUpperCase()}
+

{title}

+

{subtitle}

+
+ +
+ ); +} + +function Overview({ nodes, tasks, events, onView }) { + const online = nodes.filter((node) => node.status === "Online").length; + const activeTasks = tasks.filter((task) => + ["Pending", "Accepted", "Running"].includes(task.status), + ).length; + const uncertain = tasks.filter( + (task) => task.status === "ResultUnconfirmed", + ).length; + const latestEvents = events.slice(0, 5); + return ( + <> +
+ + + + +
+
+
+
+
+

节点健康

+

最近心跳与微信会话状态

+
+ +
+ {nodes.length === 0 ? ( + + ) : ( +
+ {nodes.slice(0, 5).map((node) => ( +
+
+ {node.node_id.slice(0, 1).toUpperCase()} +
+
+ {node.node_id} + + {node.wechat_logged_in ? "微信已登录" : "微信未登录"} ·{" "} + {formatTime(node.last_heartbeat_at)} + +
+ +
+ ))} +
+ )} +
+
+
+
+

最近白名单事件

+

中心已持久化的数据

+
+ +
+ {latestEvents.length === 0 ? ( + + ) : ( +
+ {latestEvents.map((event) => ( +
+
+
+ {shortId(event.chat_id)} + + {event.chat_type} · {formatTime(event.received_at)} + +
+ + {event.content || "无正文"} + +
+ ))} +
+ )} +
+
+ + ); +} + +function NodesView({ nodes }) { + return ( +
+
+
+

节点管理

+

节点主动出站连接,状态来自最近一次心跳。

+
+ {nodes.length} 个节点 +
+ {nodes.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {nodes.map((node) => ( + + + + + + + + + + ))} + +
节点状态微信状态活动账号能力最近心跳上报配置
+
+
+ {node.node_id.slice(0, 1).toUpperCase()} +
+
+ {node.node_id} + + {node.agent_version || "—"} ·{" "} + {node.protocol_version || "—"} + +
+
+
+ + + + {node.wechat_running + ? node.wechat_logged_in + ? "已登录" + : "未登录" + : "未运行"} + + {node.session_locked && ( + 桌面已锁定 + )} + {node.active_account_id || "—"} + + {node.capabilities?.length || 0} + {" "} + 项 + {formatTime(node.last_heartbeat_at)}v{node.reporting_config_version ?? 0}
+
+ )} +
+ ); +} + +function TasksView({ nodes, tasks, token, onChanged }) { + const [showCreate, setShowCreate] = useState(false); + const [busyId, setBusyId] = useState(""); + const [filter, setFilter] = useState("all"); + const visible = tasks.filter( + (task) => filter === "all" || task.status === filter, + ); + const cancel = async (task) => { + setBusyId(task.task_id); + try { + await api(`/v1/tasks/${encodeURIComponent(task.task_id)}/cancel`, { + token, + method: "POST", + }); + onChanged(); + } catch (error) { + window.alert(error.message); + } finally { + setBusyId(""); + } + }; + return ( + <> +
+
+
+

任务队列

+

任务按节点与账号路由,取消请求需等待节点确认。

+
+
+ + +
+
+ {visible.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {visible.map((task) => ( + + + + + + + + + + ))} + +
任务路由状态租约更新时间结果 +
+
+ +
+ {shortId(task.task_id, 16)} + + {task.kind} · {shortId(task.idempotency_key, 16)} + +
+
+
+ {task.node_id} + + 账号 {task.account_id} + + + + {task.cancel_requested_at && + ![ + "Succeeded", + "Failed", + "Cancelled", + "Expired", + "ResultUnconfirmed", + ].includes(task.status) && ( + + 取消待确认 + + )} + + {task.lease_generation + ? `第 ${task.lease_generation} 代` + : "未分配"} + {formatTime(task.updated_at)} + {task.result?.error_code ? ( + + {task.result.error_code} + + ) : ( + task.result?.message || "—" + )} + + {![ + "Succeeded", + "Failed", + "Cancelled", + "Expired", + "ResultUnconfirmed", + ].includes(task.status) && ( + + )} +
+
+ )} +
+ {showCreate && ( + setShowCreate(false)} + onCreated={() => { + setShowCreate(false); + onChanged(); + }} + /> + )} + + ); +} + +function TaskModal({ nodes, token, onClose, onCreated }) { + const [nodeId, setNodeId] = useState(nodes[0]?.node_id || ""); + const [accountId, setAccountId] = useState(nodes[0]?.active_account_id || ""); + const [targetId, setTargetId] = useState(""); + const [text, setText] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const submit = async (event) => { + event.preventDefault(); + setBusy(true); + setError(""); + try { + await api("/v1/tasks", { + token, + method: "POST", + body: { + node_id: nodeId, + account_id: accountId, + kind: "send-text", + idempotency_key: crypto.randomUUID(), + payload: { target_id: targetId, text, confirmed: true }, + }, + }); + onCreated(); + } catch (reason) { + setError(reason.message); + } finally { + setBusy(false); + } + }; + return ( +
event.target === event.currentTarget && onClose()} + > +
+
+
+
NEW REMOTE TASK
+

发送文本消息

+

仅允许经过节点命令白名单校验的 send-text 操作。

+
+ +
+
+ + + +