diff --git a/.env.example b/.env.example index 18f20ebe..cc61507b 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= +# WARMBLY_NODE_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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d3b8cf9..dbba2b1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - 'go.sum' - 'internal/**' - 'cmd/**' + # make lint runs the join-script checks, so a change to the + # checker itself has to trigger the job that runs it. + - 'scripts/check-join-script.sh' migrations: - 'internal/infrastructure/db/migrations/**' - 'scripts/check-migrations.sh' diff --git a/AGENTS.md b/AGENTS.md index 918ffe43..dac2f829 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,83 @@ 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). + +Run `make join-check` before pushing a change to it; it is a prerequisite of `make lint`. It asserts on what `join.sh --print-unit` *renders*; an earlier version compared a heredoc copied into the checker itself and stayed green when the original bug was put back. It exists because nothing covered the script and three separate defects shipped into the branch as a result: a systemd unit built with `$(cat ...)`, which systemd never expands, so the machine restart-looped while the script printed "Done"; a missing bind mount, so the node wrote its update target inside the container and auto-update silently never ran; and an env file assembled by picking a multi-line value back out of JSON with sed, which appended a stray fragment. Assert on what the shell *renders*, not on the source text: every one of those parsed fine. The two invariants that leave no trace in the rendered unit (that `main` validates before writing anything, and that `install_units` prepares the blob root) are checked at their call sites instead, matched on the first field, which a mention inside a string or a comment cannot satisfy. That does mean those calls have to stay standalone statements, which `join.sh` notes above each set of asserted calls; a looser regex was tried and turned out to be satisfied by the name appearing inside a `warn` message, which is a far worse failure than a reformat that reports itself. Every assertion there was mutation-tested: the bug it guards was reintroduced and the check was watched to fail. + +Two rules that follow from those: + +- **systemd runs no shell.** No `$(...)`, no globbing, no word splitting in a unit. A value that has to vary comes from an `EnvironmentFile` as `${VAR}`, which expands to exactly one argument +- **What the node may write and what root reads are different directories.** The container runs as uid 1000; it gets `/var/lib/warmbly/node` and nothing else. `image-ref` lives one level up, root-owned, because systemd feeds it to a root `docker run --network host` and a node that could rewrite it would choose the image root executes + +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 +487,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 +916,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..1c15f218 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ PROTOC_GEN_GO_GRPC_VERSION ?= v1.6.1 PROTO_DIR := internal/tasks/proto PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go -.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations proto check-proto \ +.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations join-check proto check-proto \ up upgrade claim doctor cli seed-demo seed seed-plan sandbox sandbox-seed sandbox-simulate reset logs status stop down test-seed \ restart restart-go restart-all infra infra-down app app-down app-logs \ backend forms forms-web consumer worker run dev tracking realtime web \ @@ -82,7 +82,7 @@ cli-check: fmt: gofmt -w ./cmd ./internal -lint: check-migrations +lint: check-migrations join-check ./scripts/check-forms-mirror.sh $(GO_BIN)/golangci-lint run --timeout=5m @@ -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 \ @@ -704,6 +703,13 @@ installer-sha: installer-check: @./scripts/check-installer.sh +# The fleet join script is served verbatim from the backend at GET /join.sh and +# is what a stranger pipes into a root shell to add a machine. Nothing covered +# it, and a systemd unit that could never start shipped as a result. Part of +# `make lint`, like check-migrations. +join-check: + @./scripts/check-join-script.sh + # Every published image has to be pullable by a stranger, and nothing else we # run proves it: a package on GHCR is created private, does not inherit the # repository's visibility, and no API can change that, so every check that 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..83e236fc 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,344 @@ 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_this_week} + {statsQ.data.total_emails_sent} + {/* Already a percentage in SQL; multiplying again gives 10000%. */} + + {`${Math.round(statsQ.data.success_rate)}%`} + +
+ )} + + {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 +372,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 +384,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" - /> -
-
- -