From 435dbb522f3a31213d30f2fcf657a4bbebc6f98f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 9 Sep 2026 04:54:01 -0700 Subject: [PATCH 01/23] feat: replace the worker tier/type/risk-pool/egress categories with a scored placement model and make the fleet pull-based, so a machine joins with one command, workers and consumers share one node registry with usage and liveness, nodes self-update to the version the control plane resolves, and the Hetzner provisioning, worker profiles and SSH orchestrator are removed --- .env.example | 6 +- AGENTS.md | 84 +- Makefile | 1 - admin/src/app/dashboard/FleetPage.tsx | 2 +- admin/src/app/dashboard/MailboxesPage.tsx | 4 +- admin/src/app/dashboard/WorkerDetailPage.tsx | 1305 +++++------------ admin/src/app/dashboard/WorkerNewPage.tsx | 546 +++---- admin/src/app/dashboard/WorkersPage.tsx | 114 +- admin/src/app/dashboard/fleet/CapacityTab.tsx | 17 +- .../fleet/ConvertDedicatedDialog.tsx | 27 +- .../src/app/dashboard/fleet/DecisionsTab.tsx | 4 +- .../src/app/dashboard/fleet/DedicatedTab.tsx | 17 +- admin/src/app/dashboard/fleet/tones.tsx | 39 +- admin/src/components/StateLegend.tsx | 4 +- .../src/components/layout/CommandPalette.tsx | 9 +- admin/src/lib/api/client/admin/fleet.ts | 23 +- admin/src/lib/api/client/admin/fleetNodes.ts | 115 ++ admin/src/lib/api/client/admin/workers.ts | 212 +-- admin/src/lib/api/models/admin.ts | 14 +- admin/src/lib/legends.ts | 26 - cmd/backend/main.go | 172 +-- cmd/consumer/main.go | 72 +- cmd/seed/main.go | 36 +- cmd/warmblyctl/fleet.go | 361 +++++ cmd/warmblyctl/main.go | 8 + cmd/worker/main.go | 93 +- docker-compose.yml | 3 +- docs/content/docs/development/admin-panel.mdx | 4 +- .../content/docs/development/architecture.mdx | 44 +- docs/content/docs/development/bare-metal.mdx | 4 +- .../docs/development/configuration.mdx | 5 +- .../docs/development/deployment-guide.mdx | 80 +- docs/content/docs/development/warmblyctl.mdx | 55 + docs/content/docs/guides/mailboxes.mdx | 22 +- docs/content/docs/learn/warmup-pools.mdx | 14 +- internal/api/handler/admin_credentials.go | 79 - internal/api/handler/admin_fleet.go | 76 +- internal/api/handler/admin_helpers.go | 66 + internal/api/handler/admin_workers_ssh.go | 692 --------- internal/api/handler/fleet_nodes.go | 579 ++++++++ internal/api/handler/handler.go | 26 +- .../api/handler/internal_worker_config.go | 101 +- internal/api/handler/join_script.go | 28 + internal/api/handler/nodescript/join.sh | 305 ++++ internal/api/handler/worker_enrollment.go | 102 -- internal/api/routes.go | 47 +- internal/app/consumer/dead_worker.go | 6 +- internal/app/consumer/risk_rebalancer.go | 101 +- internal/app/consumer/service.go | 1 + internal/app/consumer/worker_heartbeat.go | 2 +- internal/app/email/onboarding.go | 17 +- internal/app/email/reauth.go | 9 +- internal/app/email/worker_removal_test.go | 4 +- internal/app/fleet/quarantine.go | 48 +- internal/app/fleet/rebalance.go | 232 ++- internal/app/fleet/rotation_live_test.go | 202 +++ internal/app/fleet/scale.go | 89 +- internal/app/fleetnode/service.go | 175 +++ internal/app/instancecheck/checks_infra.go | 4 +- internal/app/instanceconfig/entries.go | 6 +- internal/app/provisioning/installer.go | 55 - internal/app/provisioning/rdns_verify.go | 47 - internal/app/provisioning/runner.go | 133 -- internal/app/provisioning/service.go | 413 ------ internal/app/releases/service.go | 274 ++-- internal/app/stripe/service.go | 59 +- internal/app/worker/assignment.go | 835 ++++------- internal/app/worker/assignment_test.go | 544 ------- internal/app/worker/capacity.go | 41 +- internal/app/worker/capacity_test.go | 9 +- internal/app/worker/placement.go | 186 +++ internal/app/worker/placement_test.go | 177 +++ internal/app/worker/rotation.go | 147 ++ internal/app/worker/rotation_test.go | 139 ++ internal/app/worker_orchestrator/keypair.go | 54 - .../app/worker_orchestrator/orchestrator.go | 690 --------- .../app/worker_orchestrator/ssh_client.go | 180 --- .../cloudprovider/cloudprovider.go | 132 -- .../cloudprovider/hetzner/hetzner.go | 407 ----- .../cloudprovider/hetzner/hetzner_test.go | 195 --- .../000140_worker_decategorization.down.sql | 88 ++ .../000140_worker_decategorization.up.sql | 101 ++ .../db/migrations/000141_fleet_nodes.down.sql | 78 + .../db/migrations/000141_fleet_nodes.up.sql | 128 ++ .../000142_drop_push_provisioning.down.sql | 148 ++ .../000142_drop_push_provisioning.up.sql | 41 + internal/infrastructure/db/zz_tmp_test.go | 38 + internal/models/admin.go | 29 +- internal/models/admin_ops.go | 7 +- internal/models/fleet_node.go | 135 ++ internal/models/fleet_release.go | 52 + internal/models/risk.go | 22 +- internal/models/subscriptions.go | 20 + internal/models/worker.go | 111 +- internal/pkg/nodeagent/agent.go | 220 +++ internal/repository/credential_seal_test.go | 84 -- internal/repository/pg_admin.go | 35 +- internal/repository/pg_admin_fleet.go | 17 +- internal/repository/pg_credentials.go | 352 ----- internal/repository/pg_fleet_node.go | 189 +++ internal/repository/pg_fleet_settings.go | 91 ++ internal/repository/pg_organization.go | 9 + internal/repository/pg_provisioning.go | 592 -------- internal/repository/pg_servers.go | 135 -- internal/repository/pg_worker.go | 338 ++--- internal/repository/pg_worker_health.go | 65 +- internal/repository/pg_worker_heartbeat.go | 87 -- internal/repository/pg_worker_placement.go | 246 ++++ internal/repository/pg_worker_risk.go | 121 +- internal/repository/pg_worker_ssh.go | 270 ---- .../repository/placement_queries_live_test.go | 151 ++ internal/sandbox/seed.go | 10 +- internal/seed/result.go | 9 +- internal/seed/workers.go | 36 +- scripts/install-worker.sh | 947 ------------ site/src/pages/index.astro | 10 +- 116 files changed, 6271 insertions(+), 9997 deletions(-) create mode 100644 admin/src/lib/api/client/admin/fleetNodes.ts create mode 100644 cmd/warmblyctl/fleet.go delete mode 100644 internal/api/handler/admin_credentials.go create mode 100644 internal/api/handler/admin_helpers.go delete mode 100644 internal/api/handler/admin_workers_ssh.go create mode 100644 internal/api/handler/fleet_nodes.go create mode 100644 internal/api/handler/join_script.go create mode 100755 internal/api/handler/nodescript/join.sh delete mode 100644 internal/api/handler/worker_enrollment.go create mode 100644 internal/app/fleet/rotation_live_test.go create mode 100644 internal/app/fleetnode/service.go delete mode 100644 internal/app/provisioning/installer.go delete mode 100644 internal/app/provisioning/rdns_verify.go delete mode 100644 internal/app/provisioning/runner.go delete mode 100644 internal/app/provisioning/service.go delete mode 100644 internal/app/worker/assignment_test.go create mode 100644 internal/app/worker/placement.go create mode 100644 internal/app/worker/placement_test.go create mode 100644 internal/app/worker/rotation.go create mode 100644 internal/app/worker/rotation_test.go delete mode 100644 internal/app/worker_orchestrator/keypair.go delete mode 100644 internal/app/worker_orchestrator/orchestrator.go delete mode 100644 internal/app/worker_orchestrator/ssh_client.go delete mode 100644 internal/infrastructure/cloudprovider/cloudprovider.go delete mode 100644 internal/infrastructure/cloudprovider/hetzner/hetzner.go delete mode 100644 internal/infrastructure/cloudprovider/hetzner/hetzner_test.go create mode 100644 internal/infrastructure/db/migrations/000140_worker_decategorization.down.sql create mode 100644 internal/infrastructure/db/migrations/000140_worker_decategorization.up.sql create mode 100644 internal/infrastructure/db/migrations/000141_fleet_nodes.down.sql create mode 100644 internal/infrastructure/db/migrations/000141_fleet_nodes.up.sql create mode 100644 internal/infrastructure/db/migrations/000142_drop_push_provisioning.down.sql create mode 100644 internal/infrastructure/db/migrations/000142_drop_push_provisioning.up.sql create mode 100644 internal/infrastructure/db/zz_tmp_test.go create mode 100644 internal/models/fleet_node.go create mode 100644 internal/models/fleet_release.go create mode 100644 internal/pkg/nodeagent/agent.go delete mode 100644 internal/repository/credential_seal_test.go delete mode 100644 internal/repository/pg_credentials.go create mode 100644 internal/repository/pg_fleet_node.go create mode 100644 internal/repository/pg_fleet_settings.go delete mode 100644 internal/repository/pg_provisioning.go delete mode 100644 internal/repository/pg_servers.go delete mode 100644 internal/repository/pg_worker_heartbeat.go create mode 100644 internal/repository/pg_worker_placement.go delete mode 100644 internal/repository/pg_worker_ssh.go create mode 100644 internal/repository/placement_queries_live_test.go delete mode 100755 scripts/install-worker.sh diff --git a/.env.example b/.env.example index 18f20ebe..0fe2828c 100644 --- a/.env.example +++ b/.env.example @@ -454,7 +454,7 @@ BILLING_PROVIDER=none # Workers hold no database. They reach organization keys over the backend's # internal API. An unset URL or token lets a worker start, subscribe and NEVER # register, with no log line. WORKER_BIND_IP, WORKER_PUBLIC_IP and -# WORKER_INSTALLER_PATH are not forwarded by the shipped docker-compose.yml. +# are not forwarded by the shipped docker-compose.yml. # ENCRYPTED_KEYS_PROVIDER=http # ENCRYPTED_KEYS_BACKEND_URL=http://backend:8080 # ENCRYPTED_KEYS_WORKER_TOKEN= # same value as INTERNAL_API_TOKEN @@ -468,14 +468,12 @@ BILLING_PROVIDER=none # WORKER_STATE_DIR=/data/state # WORKER_BIND_IP= # WORKER_PUBLIC_IP= -# WORKER_TIER=free # free | premium | dedicated -# WORKER_EGRESS_KIND= +# WORKER_REGION= # free-form egress location label, e.g. eu-central # MAIL_TLS_INSECURE=false # skips cert verification on mailbox connections # # Set on the BACKEND: the image the remote installer pulls. The built-in default # does not match what CI publishes. # WORKER_IMAGE=ghcr.io//warmbly/worker:prod -# WORKER_INSTALLER_PATH= # === Updates ================================================================== diff --git a/AGENTS.md b/AGENTS.md index 918ffe43..204ff62a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,7 +186,7 @@ Infra runs in docker; the Go services and frontends run natively on the host for - `make dev` — the one-command stack: brings up the docker infra and waits for postgres, applies migrations, loads seed fixtures (skip with `SEED=false`), installs web + admin deps on first run, starts realtime and tracking as containers, then runs backend + forms + consumer + worker + dashboard + admin in one terminal. Login: dev@warmbly.com / password123, with the emailed login code in Mailpit at http://localhost:18025. Ctrl-C stops the app; infra stays up. - `make infra` — start the backing services in docker (postgres, redis, nats, mailpit). Run once; leave running. Kafka, Schema Registry, localstack, cloud-tasks, and stripe-mock are gone; the stack is no-cloud by default (NATS, local KMS, filesystem blobs, in-process tasks). - `make backend` — run the API natively on `:8080` (applies the embedded migrations on boot against the docker postgres). -- `make consumer` / `make worker` / `make worker-premium` — run those Go services natively, each in its own terminal. Two native workers exist because tier placement is strict: free-trial orgs place onto the free-tier worker (`make worker`), paid orgs onto the premium one (`make worker-premium`). The workers read encrypted DEKs through the backend's `/internal/dek` endpoint (the prod `http` provider, no worker DB), so `make backend` must be running and their `INTERNAL_API_TOKEN` must match (the targets are pre-wired to match). +- `make consumer` / `make worker` — run those Go services natively, each in its own terminal. Both register themselves as fleet nodes on their first heartbeat, so they show up in `warmblyctl fleet list` without any enrolment step in dev. Workers are interchangeable, so one is enough; run a second `make worker WORKER_ID=` in another terminal when you want to watch placement spread mailboxes across a fleet. The workers read encrypted DEKs through the backend's `/internal/dek` endpoint (the prod `http` provider, no worker DB), so `make backend` must be running and their `INTERNAL_API_TOKEN` must match (the targets are pre-wired to match). - `make run` — backend + forms + consumer + worker together in one terminal (Ctrl-C stops all). - `make forms` — the public forms service natively on `:8090` (`cmd/forms`): builds the `forms/` TanStack app, then serves it plus the embed loader and public submissions. No database; it resolves forms and forwards submissions through the backend's internal API, so `make backend` must be running and `INTERNAL_API_TOKEN` must match (pre-wired). The backend's `FORMS_DOMAIN=localhost:8090` makes dashboard share links point at it; `make forms FORMS_PORT=8091` (matched on `make backend`) moves it when worktrees share the machine. `make forms-web` runs the Vite dev server (:5175) for iterating on the app itself. - `make sandbox` — fully working demo environment: seeds the "Sunrise Labs" showcase org (live mailboxes: SMTP -> mailpit, IMAP -> dovecot, credentials sealed with `CREDENTIALS_ENCRYPTION_KEY`) and runs the simulator that plays the internet (delivers mail into dovecot inboxes, opens pixels, clicks tracked links, replies as contacts). Needs `make run` + `make tracking` alongside. Docs: `docs/content/docs/development/sandbox.mdx`. @@ -294,26 +294,76 @@ API keys with the `REALTIME_SUBSCRIBE` permission (bit 11) can connect to the sa Workers are intended to run distributed across many machines, with one worker process per machine. -That layout matters because it lets the system spread sending activity across different machine-level network identities and IP addresses instead of concentrating traffic through a single sender runtime. +**There is one kind of worker.** No tier, no type, no risk pool, no egress category. You stand a worker up, it heartbeats, and the control plane decides what runs on it. The only thing an operator may set is an optional free-form `WORKER_REGION` label, and leaving it blank is fine. + +Do not reintroduce a worker category. The four that used to exist (`free_tier`, `worker_type`, `risk_pool`, `egress_kind`) were removed in migration `000140` because they all rested on a premise that is false for this architecture: that the worker's IP is the sending identity. + +It is not. A worker never talks to a recipient's MX. It authenticates to the customer's own mailbox provider, and that provider delivers from its own outbound pool. So: + +- **the worker IP is invisible to recipient spam filtering.** Google strips the submitting client's IP; Microsoft dropped `X-Originating-IP` years ago. A spam-prone mailbox therefore cannot contaminate a healthy neighbour on the same machine, which is why hard risk segregation of workers bought nothing +- **the worker IP is very visible to the mailbox provider**, where it drives sign-in risk challenges, per-IP auth throttles (`454 4.7.0`) and per-IP rate limits (`421 4.7.28`). Exchange Online also caps SMTP AUTH at ~3 concurrent connections and ~30 msg/min per mailbox, and IMAP at ~8 concurrent sessions + +The practical inversion: **IP stability per mailbox beats IP diversity.** Moving a mailbox changes the client address its provider sees and buys a security challenge for nothing, so a migration is a cost, not a win. A fleet where nothing rotates is a healthy fleet. In production, workers are treated as individually addressable executors: - each worker has its own `worker_id` - email accounts are assigned to a specific worker - worker events are delivered through worker-specific Kafka topics -- the platform can rebalance or migrate accounts between workers +- the platform can rebalance or migrate accounts between workers, reluctantly -This repo already models three worker modes: +Placement is a score, never a filter (`internal/app/worker/placement.go`). Hard constraints cover only whether the work can be done: heartbeating, health in `healthy`/`watch`, and enough capacity headroom for the mailbox's weight. Everything else is a preference term: capacity headroom, incumbency (weighted highest), region match, tenant blast radius, per-provider crowding on one address, and foreign tenants for orgs entitled to isolated egress. -- shared free-tier workers -- shared premium workers -- dedicated workers assigned to a single paying organization +Capacity is one number for every worker in cold-mailbox equivalents, because each mailbox declares its own cost through `MailboxWeight`: `smtp_imap` = 1.0, `gmail`/`outlook` = 0.05, warmup-only = 0.4. Those are the `email_provider` enum values as stored; do not invent provider strings for them. + +Rotation is gated separately (`internal/app/worker/rotation.go`) and is deliberately reluctant: + +| Urgency | Trigger | Residency floor | Destination bar | +|---|---|---|---| +| Immediate | worker inactive, not heartbeating, blocked, quarantined | none | anything eligible | +| Elevated | worker throttled | 6h | anything eligible | +| Opportunistic | worker over 85% utilization, isolated-egress drift | 72h | must beat the incumbent by `RotationMinScoreGain` | + +Isolated egress (the entitlement `plan.IsolatedEgress()`, still stored in `plans.dedicated_workers`) binds an org to a worker through `dedicated_worker_assignments`. It is a strong placement preference, not a pin: the worker carries no marking, so a reserved worker going down never strands the customer. The relevant code paths are in: -- `internal/app/worker/assignment.go` -- `internal/repository/pg_worker.go` -- `internal/infrastructure/db/migrations/000015_worker_tiers.up.sql` +- `internal/app/fleetnode/service.go` (enrolment, heartbeat, desired version) +- `internal/app/worker/placement.go` (the score) +- `internal/app/worker/rotation.go` (when a move is allowed) +- `internal/app/worker/assignment.go` (the service that commits placements) +- `internal/app/fleet/rebalance.go` (the rotation loop) +- `internal/repository/pg_worker_placement.go` +- `internal/infrastructure/db/migrations/000140_worker_decategorization.up.sql` + +## The Fleet Is Pull-Based + +Every Warmbly process that runs on a machine you own is a **node**: `worker` (sends and syncs mail) or `consumer` (processes events). Both share one lifecycle and one registry. + +A node joins by running one command with the instance join token, then heartbeats forever. **Nothing is ever pushed to a node.** Everything the control plane wants it to do comes back in the heartbeat reply, which today is exactly one instruction: what version to be running. + +Do not reintroduce a push path. Migration `000142` deleted the whole of it — the Hetzner provider, `provisioning_templates`/`_jobs`/`_policy`, `worker_profiles`, `aws_credentials`, the SSH orchestrator and every `workers.ssh_*` column — because onboarding a machine you already own does not need a cloud API or a keypair, and an update does not need someone to shell in and run it. + +Shape: + +- `fleet_nodes` is the registry every role shares: identity, region, address, version, liveness, resource usage. `workers` is the placement extension and holds only `account_count`, `health_state`, `load_score`; `workers.id` IS the node id, enforced by a foreign key +- a node is created by enrolling, never by an admin form. `EnsureWorkerRow` adds the placement half when a node declares itself a worker +- liveness lives on `fleet_nodes.last_seen_at` and nowhere else. `models.Worker` is a flat view over `workers JOIN fleet_nodes`, so read it through `workerSelect` rather than adding a second source of truth +- `models.NodeLivenessWindow` is the one definition of live. The node paces its own beat at a third of it, from the value the server returns + +Auto-update: + +- `internal/app/releases` resolves the head of the configured channel from GitHub Releases and writes the tag to `admin_settings` under `fleet.release`. It updates nothing itself +- the heartbeat reply carries `desired_version`; the node writes it to a file and a systemd timer (`warmbly-node-update`, installed by the join script) pulls and restarts. The process being replaced is never the process doing the replacing +- an empty `desired_version` means "no opinion" and must never be read as "downgrade to nothing". A node that cannot be told what to run keeps running what it has +- a per-node `pinned_version` overrides the fleet target, for canarying or holding a machine back +- **the backend is deliberately excluded.** It is what tells everyone else their version; a self-update that goes wrong leaves nothing to recover with + +The join script is `internal/api/handler/nodescript/join.sh`, embedded and served at `GET /join.sh` by the instance itself, so a self-hosted fleet never depends on a vendor host and always gets a script matching its backend. There is exactly one copy: do not add a mirror under `scripts/` or `site/public/`. All the POSIX-sh rules for published scripts apply to it (`sh -n`, `shellcheck -s sh`, everything in a function, `main "$@"` last). + +The env the join endpoint hands a node is rendered from the backend's own environment (`nodeEnvKeys` in `internal/api/handler/fleet_nodes.go`). `PRIMARY_DB` is deliberately absent: a worker reaches relational data through the internal API and nothing else, and shipping a DSN here would quietly undo that boundary. + +Operator surface: `warmblyctl fleet` (join-token, list, show, remove, pin, version, channel) and the admin panel's Fleet section. There is no install, restart, logs or reboot action anywhere, because nothing reaches into a machine. ## Warmup Pool Model @@ -430,14 +480,14 @@ Warmup posture: ### Worker-level distribution rule -For shared workers, distribute volume by mailbox budget and IP spread: +Distribute by mailbox budget, not by a per-worker sending target. Note what this rule is and is not for: spreading mailboxes across workers does **not** improve recipient-side deliverability, because the worker is not the sending identity (see Worker Topology). It limits blast radius and keeps any one address from crowding one provider's auth rate limits. -- no shared worker should become a concentration point for a large fraction of total cold-email traffic -- prefer adding more workers and spreading accounts rather than increasing per-worker density -- if one worker holds many active cold mailboxes, keep the total planned worker volume equal to the sum of those mailbox caps, not an independent higher target -- as a conservative planning heuristic, shared workers should usually stay near the equivalent of about `10` actively sending cold mailboxes at default settings, or roughly `500` cold campaign emails/day, unless there is explicit evidence that the worker/IP pool can safely sustain more +- no worker should become a concentration point for a large fraction of one customer's mailboxes, because losing it stops that fraction of their sending +- keep a worker's total planned volume equal to the sum of its mailboxes' caps, not an independent higher target +- avoid piling many mailboxes of the same provider onto one worker; that is the combination that earns a per-IP auth throttle (`providerSoftCap` in `placement.go`) +- prefer adding workers over increasing per-worker density, but do not churn existing mailboxes to achieve it -Dedicated workers may carry higher organization-specific volume, but those increases should come from more healthy mailboxes, not from forcing a small number of inboxes to send too much. +Increases in volume should come from more healthy mailboxes, never from forcing a small number of inboxes to send too much. ### Internet research constraints @@ -859,7 +909,7 @@ If a new feature requires heavy joins, admin queries, billing checks, or complex - do not add direct Postgres usage to `cmd/worker` or `internal/app/worker` unless explicitly required - preserve worker-specific Kafka topic routing -- preserve separation between free, premium, and dedicated worker capacity +- do not reintroduce worker categories; placement is a score over live state - preserve separation between free and premium warmup pools - optimize for many-worker deployments, not a single giant worker - document any change that alters worker assignment, pool membership, or network boundaries diff --git a/Makefile b/Makefile index d7824df3..d929e642 100644 --- a/Makefile +++ b/Makefile @@ -667,7 +667,6 @@ consumer: worker: $(WORKER_DEV_ENV) \ WORKER_ID=10c8f5e4-1c39-5b2a-9c8b-3d2f0a8b1a01 \ - WORKER_TIER=shared \ ENCRYPTED_KEYS_PROVIDER=http \ ENCRYPTED_KEYS_BACKEND_URL=http://localhost:8080 \ ENCRYPTED_KEYS_WORKER_TOKEN=local-dev-internal-token \ diff --git a/admin/src/app/dashboard/FleetPage.tsx b/admin/src/app/dashboard/FleetPage.tsx index a7c285cd..4a0a3f79 100644 --- a/admin/src/app/dashboard/FleetPage.tsx +++ b/admin/src/app/dashboard/FleetPage.tsx @@ -1,6 +1,6 @@ // Fleet: placement as the operator sees it. Capacity (per-worker load vs. // effective capacity with the last hour's outcome counters), the decision log -// the control loops write, and the dedicated worker bindings. The tab lives +// the control loops write, and the isolated-egress reservations. The tab lives // in ?tab= so links deep-link. import { useSearchParams } from "react-router-dom"; diff --git a/admin/src/app/dashboard/MailboxesPage.tsx b/admin/src/app/dashboard/MailboxesPage.tsx index 0569ebd0..aa4dfa75 100644 --- a/admin/src/app/dashboard/MailboxesPage.tsx +++ b/admin/src/app/dashboard/MailboxesPage.tsx @@ -24,7 +24,7 @@ import { DataTable, type Column } from "@/components/data/DataTable"; import { useCursorPager } from "@/lib/useCursorPager"; import { emptyRange, rangeActive, rangeWithin, rangeAfter, rangeBefore, type DateRange } from "@/lib/dateRange"; import { searchMailboxes } from "@/lib/api/client/admin/mailboxes"; -import { listManagedWorkers } from "@/lib/api/client/admin/workers"; +import { listFleetNodes, type FleetNode } from "@/lib/api/client/admin/fleetNodes"; import type { AdminMailboxRow } from "@/lib/api/models/admin"; type StatusFilter = "active" | "inactive" | "all"; @@ -206,7 +206,7 @@ export default function MailboxesPage() { if (qParam) setStatus("all"); }, [qParam]); - const { data: workersData } = useQuery({ queryKey: ["admin", "workers", "managed"], queryFn: listManagedWorkers, staleTime: 60_000 }); + const { data: workersData } = useQuery({ queryKey: ["admin", "workers", "managed"], queryFn: () => listFleetNodes("worker"), staleTime: 60_000 }); const workerOptions = [ { value: "any", label: "Any worker" }, ...(workersData?.data ?? []).map((w) => ({ value: w.id, label: w.name || w.id.slice(0, 8) })), diff --git a/admin/src/app/dashboard/WorkerDetailPage.tsx b/admin/src/app/dashboard/WorkerDetailPage.tsx index fc3fa583..cfe1260c 100644 --- a/admin/src/app/dashboard/WorkerDetailPage.tsx +++ b/admin/src/app/dashboard/WorkerDetailPage.tsx @@ -1,52 +1,22 @@ -// Single worker detail — overview header + the SSH lifecycle actions wired to -// the admin endpoints. Routine actions (test, install, restart, pull latest, -// apply config) sit in the header; the destructive and rarely-used ones (rotate -// keys, OS update, reboot, uninstall, delete) are grouped in a Maintenance card -// so they can't be hit by accident. Logs panel tails journald with a selectable -// line count and an optional follow mode. The worker row, its mailboxes and -// its stats are keyed under ["admin","workers"] so the realtime workers spine -// refreshes them; only the SSH probes (live status, log follow) still poll. +// One machine in the fleet. +// +// Everything shown here is reported BY the node or resolved FOR it. There is +// no install, restart, log or reboot button, because nothing reaches into a +// machine any more: a node enrols with a token, heartbeats, and pulls the +// version it should run. What an operator can actually do is rename it, hold +// it at a version, and forget it. -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { - ArrowLeft, - ArrowRightLeft, - ArrowUpCircle, - Check, - Copy, - Gauge, - Download, - Hammer, - KeyRound, - PackageOpen, - PlayCircle, - PowerOff, - RefreshCw, - RotateCcw, - ShieldAlert, - SlidersHorizontal, - StopCircle, - Trash2, - X, -} from "lucide-react"; +import { ArrowLeft, Pin, PinOff, RefreshCw, Trash2 } from "lucide-react"; import { PageHeader } from "@/components/layout/PageHeader"; -import { StateLegend } from "@/components/StateLegend"; -import { WORKER_HEALTH_LEGEND } from "@/lib/legends"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, @@ -57,930 +27,341 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { - applyWorkerConfig, - deleteWorker, - getManagedWorker, - getWorkerEmails, - getWorkerLogs, - getWorkerLiveStatus, - getWorkerStats, - listManagedWorkers, - reassignWorkerEmails, - installWorker, - rebootWorker, - restartWorker, - rotateWorkerKeys, - systemUpdateWorker, - testWorker, - uninstallWorker, - upgradeWorker, - type WorkerStats, -} from "@/lib/api/client/admin/workers"; -import type { AdminWorkerEmail, ManagedWorker } from "@/lib/api/models/admin"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { getWorkerEmails, getWorkerStats, reassignWorkerEmails } from "@/lib/api/client/admin/workers"; +import { + deleteFleetNode, + listFleetNodes, + nodeNeedsUpdate, + nodeState, + patchFleetNode, + type FleetNode, +} from "@/lib/api/client/admin/fleetNodes"; +import type { AdminWorkerEmail } from "@/lib/api/models/admin"; -// Risk band (mailbox reputation tier) + health state (warmup/worker) tones. -const RISK_TONE: Record = { - clean: "border-emerald-300 bg-emerald-50 text-emerald-700", - risky: "border-amber-300 bg-amber-50 text-amber-700", - quarantine: "border-red-300 bg-red-50 text-red-700", +const STATE_TONE: Record = { + live: "border-emerald-300 bg-emerald-50 text-emerald-700", + unreachable: "border-amber-300 bg-amber-50 text-amber-700", + stopped: "border-zinc-300 text-zinc-600", }; -const HEALTH_TONE: Record = { - healthy: "border-emerald-300 bg-emerald-50 text-emerald-700", - watch: "border-amber-300 bg-amber-50 text-amber-700", - throttled: "border-orange-300 bg-orange-50 text-orange-700", - quarantined: "border-red-300 bg-red-50 text-red-700", - blocked: "border-red-300 bg-red-50 text-red-700", -}; +function Fact({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function uptime(seconds?: number): string { + if (seconds === undefined) return "—"; + const h = Math.floor(seconds / 3600); + if (h >= 24) return `${Math.floor(h / 24)}d ${h % 24}h`; + if (h >= 1) return `${h}h ${Math.floor((seconds % 3600) / 60)}m`; + return `${Math.floor(seconds / 60)}m`; +} export default function WorkerDetailPage() { - const { id = "" } = useParams<{ id: string }>(); + const { id = "" } = useParams(); + const nav = useNavigate(); const qc = useQueryClient(); - const navigate = useNavigate(); - const workerQ = useQuery({ - queryKey: ["admin", "workers", id], - queryFn: () => getManagedWorker(id), - enabled: !!id, + const [pinDraft, setPinDraft] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [reassignOpen, setReassignOpen] = useState(false); + + const nodeQ = useQuery({ + queryKey: ["admin", "fleet", "nodes"], + queryFn: () => listFleetNodes(), + refetchInterval: 30_000, }); + const node = (nodeQ.data?.data ?? []).find((n) => n.id === id) ?? null; const statsQ = useQuery({ queryKey: ["admin", "workers", id, "stats"], queryFn: () => getWorkerStats(id), - enabled: !!id, - retry: false, + enabled: !!node && node.role === "worker", }); - const liveQ = useQuery({ - queryKey: ["admin", "worker", id, "live"], - queryFn: () => getWorkerLiveStatus(id), - enabled: !!id, - refetchInterval: 10_000, - retry: false, - }); - - const [logLines, setLogLines] = useState(200); - const [followLogs, setFollowLogs] = useState(false); - const logScrollRef = useRef(null); - - const logsQ = useQuery({ - queryKey: ["admin", "worker", id, "logs", logLines], - queryFn: () => getWorkerLogs(id, logLines), - enabled: !!id, - retry: false, - refetchInterval: followLogs ? 5_000 : false, - }); - - // Follow mode pins the viewport to the newest lines on every refetch. - useEffect(() => { - if (!followLogs) return; - const el = logScrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [followLogs, logsQ.data]); - - const copyLogs = async () => { - if (!logsQ.data?.logs) return; - try { - await navigator.clipboard.writeText(logsQ.data.logs); - toast.success("Logs copied to clipboard"); - } catch { - toast.error("Could not copy logs"); - } - }; - const emailsQ = useQuery({ queryKey: ["admin", "workers", id, "emails"], queryFn: () => getWorkerEmails(id), - enabled: !!id, + enabled: !!node && node.role === "worker", }); - // Mailbox selection for the reassign action; cleared when the list changes. - const [selected, setSelected] = useState>(new Set()); - const [reassignOpen, setReassignOpen] = useState(false); + const patch = useMutation({ + mutationFn: (body: { name?: string; pinned_version?: string }) => patchFleetNode(id, body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "fleet"] }); + toast.success("Node updated"); + }, + onError: (e: Error) => toast.error(e.message || "Update failed"), + }); + + const remove = useMutation({ + mutationFn: () => deleteFleetNode(id), + onSuccess: (res) => { + toast.success(res.note || "Node removed"); + nav("/workers"); + }, + onError: (e: Error) => toast.error(e.message || "Remove failed"), + }); + + if (nodeQ.isLoading) { + return ; + } + if (!node) { + return ( +
+ + +
+ ); + } + + const state = nodeState(node); const mailboxes = emailsQ.data?.data ?? []; - const allSelected = mailboxes.length > 0 && mailboxes.every((m) => selected.has(m.id)); - - function toggleOne(mid: string) { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(mid)) next.delete(mid); - else next.add(mid); - return next; - }); - } - - function toggleAll() { - setSelected(allSelected ? new Set() : new Set(mailboxes.map((m) => m.id))); - } - - const invalidate = () => { - qc.invalidateQueries({ queryKey: ["admin", "workers"] }); - qc.invalidateQueries({ queryKey: ["admin", "worker", id] }); - }; - - const testMut = useMutation({ - mutationFn: () => testWorker(id), - onSuccess: (res) => { - toast.success(res.ok ? "SSH reachable" : res.error || "SSH unreachable"); - invalidate(); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const installMut = useMutation({ - mutationFn: () => installWorker(id), - onSuccess: () => { - toast.success("Install kicked off"); - invalidate(); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const restartMut = useMutation({ - mutationFn: () => restartWorker(id), - onSuccess: () => { - toast.success("Worker restarting"); - invalidate(); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const upgradeMut = useMutation({ - mutationFn: () => upgradeWorker(id), - onSuccess: () => { - toast.success("Pulling the latest image and restarting"); - invalidate(); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const applyMut = useMutation({ - mutationFn: () => applyWorkerConfig(id), - onSuccess: () => { - toast.success("Config rewritten and worker restarted"); - invalidate(); - }, - onError: (e: Error) => toast.error(e.message), - }); - - // The rotated public key is shown once here; it has to reach the VPS's - // authorized_keys or every later SSH action fails. - const [rotatedKey, setRotatedKey] = useState(null); - const rotateMut = useMutation({ - mutationFn: () => rotateWorkerKeys(id), - onSuccess: (res) => { - setRotatedKey(res.ssh_public_key); - toast.success("New keypair generated"); - invalidate(); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const [systemUpdateOutput, setSystemUpdateOutput] = useState(null); - const systemUpdateMut = useMutation({ - mutationFn: () => systemUpdateWorker(id), - onSuccess: (res) => { - setSystemUpdateOutput(res.output); - toast.success( - res.reboot_required - ? "OS packages updated. A reboot is required." - : "OS packages updated", - ); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const [confirmReboot, setConfirmReboot] = useState(false); - const rebootMut = useMutation({ - mutationFn: () => rebootWorker(id), - onSuccess: () => { - toast.success("Reboot issued"); - setConfirmReboot(false); - invalidate(); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const [confirmUninstall, setConfirmUninstall] = useState(false); - const uninstallMut = useMutation({ - mutationFn: () => uninstallWorker(id), - onSuccess: () => { - toast.success("Uninstall scheduled"); - setConfirmUninstall(false); - invalidate(); - }, - onError: (e: Error) => toast.error(e.message), - }); - - const [confirmDelete, setConfirmDelete] = useState(false); - const deleteMut = useMutation({ - mutationFn: () => deleteWorker(id), - onSuccess: () => { - toast.success("Worker deleted"); - qc.invalidateQueries({ queryKey: ["admin", "workers", "managed"] }); - navigate("/workers"); - }, - onError: (e: Error) => toast.error(e.message), - }); - - if (workerQ.isLoading) { - return ( -
- - -
- ); - } - - if (workerQ.isError || !workerQ.data) { - return ( -
- -

- Worker {id} isn't in the managed-worker registry. It may have been - deleted, or you may not have permission to view it. -

- - Back to workers - -
- ); - } - - const w = workerQ.data; return ( -
- - - All workers - - - - - - - +
+ +
+ + +
- {rotatedKey && ( -
-
- -
-

- New public key. Add it to the VPS before the next action. -

-

- Until this lands in ~/.ssh/authorized_keys on the - machine, every SSH action here will fail. -

-
-                                {rotatedKey}
-                            
-
- - -
-
-
-
- )} - - {systemUpdateOutput && ( -
-
- Package manager output - -
-
-                        {systemUpdateOutput}
-                    
-
- )} - - {confirmUninstall && ( -
- -
- Uninstalling will stop the worker process, drop the systemd unit, and detach - the machine from the fleet. Existing mailbox assignments will need to be - re-routed manually before you do this. - -
-
- )} - - {confirmDelete && ( -
- -
- Deleting removes the worker row and its stored SSH key. It does not stop - anything still running on the machine, so uninstall first unless the box is - already gone. - -
-
- )} - -
- - - SSH target - How the control plane reaches this worker. - - - - - - - {w.ssh_host_fingerprint && ( - - )} - - - - - - Runtime - Live status from the worker daemon. - - - {liveQ.isLoading && } - {liveQ.isError && ( -
- Live status unavailable (worker offline or SSH unreachable). -
- )} - {liveQ.data && ( - <> - - {liveQ.data.service_active ? "active" : "inactive"} - - } - /> - - {liveQ.data.container_up ? "up" : "down"} - - } - /> - - - - )} -
-
- - - - Fleet position - How this worker is being used today. -
- -
-
- - - {w.health_state} - - } - /> - - - - {w.tags && w.tags.length > 0 && ( -
- {w.tags.map((t) => ( - - {t} - - ))} -
- )} -
-
- - -
- - + - - Mailboxes - - + Machine - Inboxes assigned to this worker and their health. Risk band drives which - workers a mailbox may share — low-health inboxes are kept off trusted workers. - Select rows to move them to another worker of the same tier. + Reported by the node on its last heartbeat. - - {emailsQ.isLoading && } - {emailsQ.isError && ( -
Could not load mailboxes.
- )} - {emailsQ.data && - ((emailsQ.data.data ?? []).length === 0 ? ( -
- No mailboxes assigned to this worker. -
+ + + + {state} + + + + {nodeNeedsUpdate(node) ? ( + + {node.version || "—"} + + {node.desired_version} + ) : ( - <> -
- - - - - - - - - - - - - - - {(emailsQ.data.data ?? []).map((m) => ( - toggleOne(m.id)} - className={`border-t border-border cursor-pointer ${selected.has(m.id) ? "bg-[var(--admin-accent-soft)]" : "hover:bg-muted/40"}`} - > - - - - - - - - - - ))} - -
- - MailboxProviderStatusRisk bandWarmup healthSpamSynced
e.stopPropagation()}> - toggleOne(m.id)} - aria-label={`Select ${m.email}`} - /> - {m.email} - {m.provider} - {m.warmup_enabled && ( - warming - )} - {m.status} - - {m.risk_band} - - - {m.warmup_health ? ( - - {m.warmup_health} - - ) : ( - - )} - - {m.spam_score ?? "—"} - - {m.last_synced_at - ? new Date(m.last_synced_at).toLocaleDateString() - : "—"} -
-
- {emailsQ.data.pagination?.has_more && ( -
- Showing the first {(emailsQ.data.data ?? []).length} - {emailsQ.data.pagination.total != null - ? ` of ${emailsQ.data.pagination.total}` - : ""}{" "} - — use the Mailboxes explorer for the full list. -
- )} - - ))} + {node.version || "—"} + )} +
+ + {node.address || "—"} + + + {node.region || "—"} + + + {node.usage?.memory_mb !== undefined ? `${node.usage.memory_mb} MB` : "—"} + + {node.usage?.goroutines ?? "—"} + {uptime(node.usage?.uptime_seconds)} + + {node.last_seen_at ? new Date(node.last_seen_at).toLocaleString() : "never"} + + {new Date(node.enrolled_at).toLocaleString()} + + {node.id} + + {node.last_error && ( +
+ + {node.last_error} + +
+ )}
- setReassignOpen(true)} - onClear={() => setSelected(new Set())} - /> + + + Version + + The node pulls whatever the fleet is set to. Pin it to hold this one + machine back, or to canary a release on it before the rest follow. + + + +
+ setPinDraft(e.target.value)} + placeholder={node.pinned_version || "v1.4.2"} + className="w-48" + /> +
+ + {node.pinned_version && ( + + )} +
+
+ + {node.role === "worker" && ( + + + + Mailboxes {mailboxes.length > 0 && `(${mailboxes.length})`} + + + Placement assigns these; you never have to. Moving one by hand is + temporary — the rotation loop re-places it if it disagrees. + + + + {statsQ.data && ( +
+ {statsQ.data.emails_sent_today} + {statsQ.data.emails_sent_total} + {statsQ.data.active_campaigns} + {statsQ.data.warmup_emails} +
+ )} + + {emailsQ.isLoading ? ( + + ) : mailboxes.length === 0 ? ( +

+ No mailboxes on this worker yet. +

+ ) : ( +
+ {mailboxes.map((m: AdminWorkerEmail) => ( + + ))} +
+ )} + + {selected.size > 0 && ( + + )} +
+
+ )} + + + + Remove + + Forgets the node. Any mailboxes it carries are re-placed within a few + minutes. It does not stop the process: a machine that is still running + re-joins on its next heartbeat, so stop the service there too. + + + + + + selected.has(m.id))} + source={node} + mailboxIds={[...selected]} onDone={() => { setSelected(new Set()); - invalidate(); + emailsQ.refetch(); + nodeQ.refetch(); }} /> - - - - - Recent logs -
- - - - -
-
- - Tail of the worker's systemd journal, last {logLines} lines pulled over - SSH. Follow refetches every 5s and keeps the newest lines in view. - -
- - {logsQ.isLoading && } - {logsQ.isError && ( -
- Could not fetch logs (worker offline or SSH unreachable). -
- )} - {logsQ.data && ( -
-
-                                {logsQ.data.logs || "(no log output)"}
-                            
-
- )} - {logsQ.data?.logs && ( - - )} -
-
- - - - Maintenance - - Host-level and destructive operations. Each one acts on the machine over - SSH, so the worker must be reachable. - - - -
- - - - - -
-
-
); } -function KV({ - label, - value, - mono, -}: { - label: string; - value: React.ReactNode; - mono?: boolean; -}) { - return ( -
- {label} - - {value} - -
- ); -} - -function CapacityCard({ - stats, - loading, - error, -}: { - stats: WorkerStats | undefined; - loading: boolean; - error: boolean; -}) { - return ( - - - - - Capacity - - Send throughput and queue depth for this worker. - - - {loading && } - {error &&
Stats unavailable.
} - {stats && ( - <> - - - - 0 ? "text-amber-700" : ""}> - {stats.success_rate.toFixed(1)}% - - } - /> - - 0 ? "font-medium" : ""}>{stats.queue_depth.toLocaleString()}} - /> - - )} -
-
- ); -} - -// Floating bottom-center bar for the mailbox selection. Fixed, so it stays -// in view however long the table is. -function SelectionBar({ count, onMove, onClear }: { count: number; onMove: () => void; onClear: () => void }) { - if (count === 0) return null; - return ( -
-
- - {count} selected -
- - -
- ); -} - -const HEALTH_LABEL: Record = { - healthy: "healthy", - watch: "watch", - throttled: "throttled", - quarantined: "quarantined", - blocked: "blocked", -}; - -// Pick a target worker for the selected mailboxes. Mailboxes keep their -// tier, so a worker in the other tier is listed but cannot be chosen. function ReassignDialog({ open, onOpenChange, source, - mailboxes, + mailboxIds, onDone, }: { open: boolean; onOpenChange: (v: boolean) => void; - source: ManagedWorker; - mailboxes: AdminWorkerEmail[]; + source: FleetNode; + mailboxIds: string[]; onDone: () => void; }) { const [target, setTarget] = useState(""); const workersQ = useQuery({ - queryKey: ["admin", "workers", "managed"], - queryFn: listManagedWorkers, + queryKey: ["admin", "fleet", "nodes", "worker"], + queryFn: () => listFleetNodes("worker"), enabled: open, staleTime: 30_000, }); @@ -988,13 +369,11 @@ function ReassignDialog({ const chosen = candidates.find((x) => x.id === target) ?? null; const mutation = useMutation({ - mutationFn: () => - reassignWorkerEmails( - target, - mailboxes.map((m) => m.id), - ), + mutationFn: () => reassignWorkerEmails(target, mailboxIds), onSuccess: () => { - toast.success(`${mailboxes.length} mailbox${mailboxes.length === 1 ? "" : "es"} moved to ${chosen?.name || target.slice(0, 8)}`); + toast.success( + `${mailboxIds.length} mailbox${mailboxIds.length === 1 ? "" : "es"} moved`, + ); setTarget(""); onDone(); onOpenChange(false); @@ -1002,79 +381,59 @@ function ReassignDialog({ onError: (e: Error) => toast.error(e.message || "Reassign failed"), }); - const sameTier = !!chosen && chosen.free_tier === source.free_tier; - return ( - { - if (!v && mutation.isPending) return; - if (!v) setTarget(""); - onOpenChange(v); - }} - > + - Move {mailboxes.length} mailbox{mailboxes.length === 1 ? "" : "es"} to another worker + + Move {mailboxIds.length} mailbox{mailboxIds.length === 1 ? "" : "es"} + - Sending and sync for these mailboxes continue from the target on its next heartbeat. Tier - placement is strict: a {source.free_tier ? "free" : "premium"}-tier mailbox only runs on a{" "} - {source.free_tier ? "free" : "premium"}-tier worker. + Sending and sync continue from the target on its next heartbeat. Any + worker can host any mailbox, so this is only worth doing when you know + something placement does not. -
-
- {mailboxes.map((m) => ( -
- {m.email} + -
-
Target worker
- - {chosen && chosen.worker_type === "dedicated" && ( -

- That worker is dedicated to one workspace; only move mailboxes that belong to it. -

- )} - {chosen && chosen.health_state !== "healthy" && ( -

- That worker is {chosen.health_state}; the assignment loop would not place new mailboxes there. -

- )} -
-
+ {chosen && nodeState(chosen) !== "live" && ( +

+ That node is {nodeState(chosen)}; placement would not choose it, and the + rotation loop will move these mailboxes off it again. +

+ )} - - diff --git a/admin/src/app/dashboard/WorkerNewPage.tsx b/admin/src/app/dashboard/WorkerNewPage.tsx index 7105dd3d..ed5d9980 100644 --- a/admin/src/app/dashboard/WorkerNewPage.tsx +++ b/admin/src/app/dashboard/WorkerNewPage.tsx @@ -1,376 +1,222 @@ -// Add a worker. +// Add a machine to the fleet. // -// Two ways to attach a machine, both ending at the same place: -// -// SSH-managed — we mint a keypair, you paste the public key into the VPS, -// then Test and Install run from here. -// Enrollment — we mint a one-time token, you run one curl on the VPS and -// it configures itself. No inbound SSH needed. -// -// Only shared workers can be created: the backend rejects worker_type -// "dedicated" because dedicated capacity is allocated by the control plane. +// There is no form here worth filling in, because there is nothing to +// configure: you issue a token, run one command on a machine you already own, +// and it appears. Everything it needs — event bus, cache, keys, the version to +// run — is handed to it by the control plane at join time, so the only two +// choices are what the machine does (worker or consumer) and, optionally, +// where it is. -import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; -import { ArrowLeft, CheckCircle2, Hammer, PlayCircle, Plug, XCircle } from "lucide-react"; +import { ArrowLeft, Check, Copy, KeyRound, Server, Wrench } from "lucide-react"; import { PageHeader } from "@/components/layout/PageHeader"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; import { API_URL } from "@/lib/env"; -import { - createWorker, - installWorker, - preflightWorker, - testWorker, -} from "@/lib/api/client/admin/workers"; -import type { CreateWorkerResponse } from "@/lib/api/models/admin"; +import { issueJoinToken, type NodeRole } from "@/lib/api/client/admin/fleetNodes"; -type Method = "ssh" | "enroll"; +// The instance a node should point at. API_URL carries the /api/v1 prefix the +// client uses; the join command wants the bare origin. +function instanceOrigin(): string { + try { + return new URL(API_URL, window.location.origin).origin; + } catch { + return window.location.origin; + } +} + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} export default function WorkerNewPage() { - const navigate = useNavigate(); + const [role, setRole] = useState("worker"); + const [region, setRegion] = useState(""); + const [token, setToken] = useState(null); - const [method, setMethod] = useState("ssh"); - const [name, setName] = useState(""); - const [notes, setNotes] = useState(""); - const [freeTier, setFreeTier] = useState(false); - const [host, setHost] = useState(""); - const [port, setPort] = useState(22); - const [user, setUser] = useState("root"); + const origin = instanceOrigin(); - const [preflight, setPreflight] = useState<{ - ok: boolean; - latency_ms?: number; - error?: string; - } | null>(null); - const [created, setCreated] = useState(null); - - const preflightMut = useMutation({ - mutationFn: () => preflightWorker(host, port), - onSuccess: (res) => setPreflight(res), - onError: (e: Error) => setPreflight({ ok: false, error: e.message }), - }); - - const createMut = useMutation({ - mutationFn: () => - createWorker({ - name, - notes, - worker_type: "shared", - free_tier: freeTier, - ssh_host: host, - ssh_port: port, - ssh_user: user, - generate_enrollment_token: method === "enroll", - }), + const issue = useMutation({ + mutationFn: issueJoinToken, onSuccess: (res) => { - setCreated(res); - toast.success("Worker created"); + setToken(res.token); + toast.success("Join token issued"); }, - onError: (e: Error) => toast.error(e.message), + onError: (e: Error) => toast.error(e.message || "Could not issue a token"), }); - const testMut = useMutation({ - mutationFn: () => testWorker(created!.id), - onSuccess: (res) => - res.ok - ? toast.success("SSH reachable") - : toast.error(res.error || "SSH unreachable"), - onError: (e: Error) => toast.error(e.message), - }); - - const installMut = useMutation({ - mutationFn: () => installWorker(created!.id), - onSuccess: () => toast.success("Install kicked off"), - onError: (e: Error) => toast.error(e.message), - }); - - // The enrollment path does not need a reachable host up front, so only the - // SSH path gates on name + host. - const canCreate = name.trim() !== "" && host.trim() !== ""; - - const enrollCommand = created?.enrollment_token - ? `curl -fsSL ${API_URL}/worker-install.sh | sudo bash -s -- \\\n --enroll ${created.enrollment_token} --api-base ${API_URL}` - : ""; - - const copy = (text: string, what: string) => { - navigator.clipboard.writeText(text); - toast.success(`${what} copied`); - }; - - if (created) { - return ( -
- - - All workers - - - - {created.enrollment_token ? ( - - - Run this on the VPS - - One-time token, valid for{" "} - {Math.round((created.enrollment_token_ttl_seconds ?? 7200) / 3600)}{" "} - hours. It is shown once and cannot be retrieved later. - - - -
-                                {enrollCommand}
-                            
- -
-
- ) : ( - - - Add this key to the VPS - - Append it to ~/.ssh/authorized_keys for{" "} - {created.ssh_user || user}, then Test connection. The - first success pins the host fingerprint. - - - -
-                                {created.ssh_public_key}
-                            
- -
-
- )} - -
- - - -
-
- ); - } + const command = useMemo(() => { + const t = token ?? ""; + const parts = [ + `curl -fsSL ${origin}/join.sh | sh -s -- \\`, + ` --url ${origin} \\`, + ` --token ${t} \\`, + ` --role ${role}`, + ]; + if (region.trim()) parts[parts.length - 1] += ` \\`; + if (region.trim()) parts.push(` --region ${region.trim()}`); + return parts.join("\n"); + }, [origin, token, role, region]); return ( -
- - - All workers - +
- -
- - - How should we reach it? - - Both paths end with the same worker. Pick enrollment if the machine - cannot accept inbound SSH from here. - - - - setMethod("ssh")} - title="SSH-managed" - body="We generate a keypair. You paste the public key onto the machine, and every later action (install, restart, logs, reboot) runs from this panel." - /> - setMethod("enroll")} - title="Enrollment token" - body="We generate a one-time token. You run a single curl on the machine and it configures itself and starts reporting in." - /> - - - - - - Machine - - The host is stored either way; it identifies the worker and is what SSH - actions dial. - - - -
- - setName(e.target.value)} - placeholder="eu-west-1" - /> -
-
-
- - { - setHost(e.target.value); - setPreflight(null); - }} - placeholder="203.0.113.10" - /> -
-
- - { - setPort(Number(e.target.value) || 22); - setPreflight(null); - }} - /> -
-
-
- - setUser(e.target.value)} - placeholder="root" - /> -
-
- -