Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00d4307346 | ||
|
|
7e767ecb4f | ||
|
|
18495dba68 | ||
|
|
689d0e1d5b | ||
|
|
95527b3956 | ||
|
|
225b53bfa7 | ||
|
|
965419b2b8 | ||
|
|
f6988b0f1e | ||
|
|
0784ef3719 | ||
|
|
9df4a29210 | ||
|
|
4c88d6e768 | ||
|
|
a85a354e57 | ||
|
|
9b18d09d9b | ||
|
|
a398da0eac | ||
|
|
edb8406e05 | ||
|
|
bfd185adbb | ||
|
|
182752d9ab | ||
|
|
3b4c87a292 | ||
|
|
2685e9ad06 | ||
|
|
4de67e4bea | ||
|
|
524ccc6412 | ||
|
|
be4f488db3 | ||
|
|
2f60b81962 | ||
|
|
92692de94d | ||
|
|
a5f9fca59e | ||
|
|
72e5228351 | ||
|
|
33b5ec0788 | ||
|
|
1b718e7c59 | ||
|
|
6ad65a1242 | ||
|
|
a41f2b26cc | ||
|
|
71f9a9dca5 | ||
|
|
449684ceaa | ||
|
|
cdc50b7aaf | ||
|
|
f534b74066 | ||
|
|
3c15ee15ef | ||
|
|
45a4f968d6 | ||
|
|
df09e42cf2 | ||
|
|
ee427ed6e1 | ||
|
|
5856deede3 | ||
|
|
e02b263054 | ||
|
|
1d6c89c368 | ||
|
|
0edfddb710 | ||
|
|
71a4f53bed | ||
|
|
de7350fce9 | ||
|
|
21786fa1a8 | ||
|
|
84f9587b9a | ||
|
|
a20e165ac2 | ||
|
|
ed260cd86c | ||
|
|
99cd2a8ec3 | ||
|
|
a228ab24a0 | ||
|
|
3acbf01d46 | ||
|
|
675689a458 | ||
|
|
42f3f3e640 | ||
|
|
21238fe707 | ||
|
|
ef86ef04a1 | ||
|
|
727bb09eff | ||
|
|
1fe4ba5999 | ||
|
|
e434beec7a | ||
|
|
fe1dfe472a | ||
|
|
0cfaf6670c | ||
|
|
4d67341ba5 | ||
|
|
1fa9160c59 | ||
|
|
d559cccd44 | ||
|
|
0684d84609 | ||
|
|
78f1bf853c | ||
|
|
e28238191d | ||
|
|
82bcc5776f | ||
|
|
1993802c38 | ||
|
|
5db49b6b0e | ||
|
|
0c15b25ecd | ||
|
|
0c21765da3 | ||
|
|
4ff8fc8d51 | ||
|
|
483053b9a2 | ||
|
|
fd4c51f3db | ||
|
|
1b351cfca4 | ||
|
|
cf9d85b3cd | ||
|
|
6a4ef5b6c6 | ||
|
|
501cf4e733 | ||
|
|
89c21d752a | ||
|
|
0e38d9d500 | ||
|
|
3511c34daa | ||
|
|
0838d1d735 | ||
|
|
d1769fc886 | ||
|
|
6dced22499 | ||
|
|
5cee53dc5f | ||
|
|
81248bb159 | ||
|
|
6354d54de8 | ||
|
|
da6d64f95c | ||
|
|
9ba3d4a61f | ||
|
|
eee236a072 | ||
|
|
9df89e2db4 | ||
|
|
f60c509b47 | ||
|
|
84dfcfeac7 | ||
|
|
5dda3b5c4a | ||
|
|
db64320bd8 | ||
|
|
583f60771c | ||
|
|
a92c3190c2 | ||
|
|
3a6d24fe0e | ||
|
|
c277ecff44 | ||
|
|
bd690c94c3 | ||
|
|
a22fdf197e | ||
|
|
bd24b03cac | ||
|
|
3afc4ab012 | ||
|
|
d1ac3e98ce | ||
|
|
5bba54f3e5 | ||
|
|
fe7bc300e2 | ||
|
|
00c03c365d |
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Grep",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Python314/Scripts/graphify.EXE hook-guard search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "C:/Python314/Scripts/graphify.EXE hook-guard read"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,24 @@ jobs:
|
||||
docker login ${{ vars.DOCKER_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Verify the OpenAPI document is current
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
|
||||
cd server
|
||||
swag init --generalInfo cmd/main.go --dir ./,../shared \
|
||||
--output internal/api/docs --outputTypes json --v3.1
|
||||
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
|
||||
git diff --exit-code internal/api/docs/openapi.json
|
||||
|
||||
- name: Build and push server image
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
|
||||
+2
-1
@@ -14,4 +14,5 @@ installer/checksums-msi.txt
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
graphify-out
|
||||
docker-compose.live.yml
|
||||
docker-compose.live.yml
|
||||
.claude
|
||||
@@ -147,7 +147,7 @@ with `"schedule"` as the actor, so there is no second dispatch path and the run
|
||||
detail page needed no changes.
|
||||
|
||||
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
|
||||
builds on Alpine, which ships no zone database, so without it
|
||||
runs on `scratch`, which ships no zone database, so without it
|
||||
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
|
||||
back to UTC — an hour wrong for half the year, in the direction nobody notices
|
||||
until a maintenance window lands in business hours. It works on a developer
|
||||
@@ -315,10 +315,179 @@ a Service cannot address the one pod holding a console listener.
|
||||
|
||||
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
|
||||
|
||||
### Package inventory and CVE findings
|
||||
|
||||
Agents report their installed packages hourly; the control plane matches them
|
||||
against distribution security feeds and raises findings that link to the
|
||||
existing `ApplyUpdatesCmd` patching path. Gated by the `vuln_scanning` licence
|
||||
feature, **checked at collection rather than display** — an ungated instance
|
||||
stores no inventory, and storage is the expensive half.
|
||||
|
||||
**Matching uses distribution feeds, never NVD version ranges.** Distributions
|
||||
backport security fixes without changing the upstream version: Ubuntu's
|
||||
`openssl 3.0.2-0ubuntu1.15` is patched against CVE-2023-0286 while NVD still
|
||||
calls 3.0.2 vulnerable. Matching on NVD would report a fully patched fleet as
|
||||
critical, and once the first report is mostly wrong nobody reads the second.
|
||||
`trivy-db` is those feeds pre-merged; `server/internal/vulndb` pulls it as an
|
||||
OCI artifact to an ephemeral directory. Version comparison is bought from
|
||||
`go-deb-version`/`go-rpm-version`/`go-apk-version` because dpkg epochs, `~`
|
||||
sorting before the empty string, and `rpmvercmp` are each a silent false
|
||||
negative waiting to happen.
|
||||
|
||||
**Only the leader matches.** `ReportPackages` upserts the list and sets
|
||||
`scan_pending`; it does not scan. `vulnsched` runs inside the existing
|
||||
`bus.RunAsLeader("housekeeping", …)` and does the matching, because otherwise
|
||||
every replica needs the ~50MB database resident and a database refresh has N
|
||||
replicas rescanning the same fleet and sending N digests. The tick is also the
|
||||
digest's batch boundary, which is what makes "one message, not five hundred"
|
||||
structural rather than a debounce someone maintains.
|
||||
|
||||
Findings are **never deleted when a package is patched** — the state moves to
|
||||
`fixed`, so "what did we remediate last quarter" stays answerable. Acceptance
|
||||
requires a reason and an expiry, and reopens automatically: permanent dismissal
|
||||
is where risk goes to be forgotten. An unsupported distribution reports
|
||||
`status: unsupported`, never "0 findings"; claiming clean when the truth is
|
||||
unknown is the same lie as a silently stale database, which is why
|
||||
`vulndb_meta.pulled_at` is on screen rather than only in a log.
|
||||
|
||||
**`server/Dockerfile`'s runtime stage is `scratch`, so it carries an explicitly
|
||||
copied `/tmp`.** The scheduler unpacks the database to a temporary directory,
|
||||
and a scratch image has none — the failure is `vulnsched: temp dir: stat /tmp:
|
||||
no such file or directory`, logged once at boot while every other subsystem
|
||||
runs normally, so the only symptom is a fleet that never reports a finding.
|
||||
|
||||
Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
|
||||
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
|
||||
scheduler off entirely.
|
||||
|
||||
### Workload registry
|
||||
|
||||
A **workload** is one Docker container or one systemd unit — one word for the
|
||||
page, the collection and the commands, rather than saying "container or
|
||||
service" in every identifier. Linux only, and **not gated by licence**: this
|
||||
reads as core fleet management, so v1 ships everywhere with no `HasFeature`
|
||||
check. If that changes the check belongs at `ReportWorkloads`, gating collection
|
||||
rather than display, exactly as sub-project A does.
|
||||
|
||||
Agents collect on a 60-second ticker and report through `ReportWorkloads` with
|
||||
the **offer-then-send** handshake the package report already uses. The offer is
|
||||
identified by an explicit `full` flag, **not by an empty workloads list**: a
|
||||
host genuinely running nothing sends an empty list as its full report, and
|
||||
inferring the offer from emptiness leaves that host answering `need_full` every
|
||||
60 seconds forever and never storing anything.
|
||||
|
||||
**The on-demand refresh returns no data.** `RefreshWorkloadsCmd` carries nothing
|
||||
back; it makes the agent report through the normal RPC and the UI refetches. A
|
||||
refresh that returned workloads inline would be a second writer for
|
||||
`server_workloads`, arriving by a different route with its own serialisation and
|
||||
its own opportunity to disagree with the periodic one. One writer, one shape.
|
||||
Opening the panel dispatches a refresh because the panel has a Restart button on
|
||||
it, and a stale row is a wrong action aimed at a container that already died.
|
||||
|
||||
Two operations do answer back, both over the bus, both with `Await` called
|
||||
**before** dispatch: control actions reuse the existing `CommandResult`, and log
|
||||
reads get `WorkloadLogsResult`. `CommandStream` republishes **every**
|
||||
`CommandResult` onto `bus.ResultChannel` — publishing with no subscriber is a
|
||||
no-op, so this costs nothing and avoids a second result path.
|
||||
|
||||
**The protected set is computed agent-side and enforced agent-side.**
|
||||
`vantage-agent.service`, plus the container ID read from `/proc/self/cgroup`
|
||||
should the agent ever run in a container. As with the console relay hardcoding
|
||||
`127.0.0.1`, the control plane may name a target but the agent decides what it
|
||||
will do to itself; a server-side denylist alone would be bypassed by the next
|
||||
dispatch path someone adds, and the failure is unrecoverable from the UI. The
|
||||
reported `Protected` flag is the courtesy that greys the button; the agent's own
|
||||
check is the boundary. The API answers **409** when it fires — nothing failed.
|
||||
|
||||
Collection avoids parsing English: `docker ps -aq` then
|
||||
`docker inspect --format '{{json .}}'`, because `docker ps` reports health and
|
||||
uptime inside a human `Status` string that is localised and reworded between
|
||||
releases. Compose stacks come from the `com.docker.compose.project` label, never
|
||||
from YAML on disk — a compose file there may not be what is running. systemd
|
||||
uses **column** output, not `--output=json`, which needs systemd 246+.
|
||||
|
||||
`DockerOK`/`DockerError` are two fields because there are three states: not
|
||||
installed (common on this fleet, and not a fault), installed but not responding,
|
||||
and running nothing. The UI must render the first as "not in use here" rather
|
||||
than an empty list.
|
||||
|
||||
Logs are capped at **500 lines and 256KB, whichever binds first** — a line count
|
||||
alone does not bound size, and 500 lines of 4KB JSON is 2MB across the bus. The
|
||||
cap is mirrored in `services.MaxWorkloadLogLines` because `agent/` is a separate
|
||||
module with an `internal/` tree and the constant cannot be shared; change one,
|
||||
change the other. There is **no follow mode**: the browser console already gives
|
||||
a real terminal where `docker logs -f` works properly. Log reads and control
|
||||
actions are **owner|admin and audited**, unlike the read-only snapshot — a
|
||||
container's stdout is arbitrary and cannot be masked the way a workflow's can.
|
||||
|
||||
`server_workloads` is one document per server, mirroring `server_packages`, and
|
||||
is in `ScopedCollections` (which `scopedCollectionsForPurge` derives from). There
|
||||
is no history: a workload list is state, not a record.
|
||||
|
||||
**`proto/vantage/v1/vantage.proto` is documentation, not a generator input.**
|
||||
Both `pb` packages are hand-written JSON-tagged structs over a custom codec, and
|
||||
there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`.
|
||||
A message added to one must be added to the other and to the `.proto`, in the
|
||||
same commit.
|
||||
|
||||
### Agent self-update
|
||||
|
||||
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
|
||||
|
||||
### API tokens and OpenAPI
|
||||
|
||||
A token is `vt_` plus 32 random bytes hex, shown once at creation and stored
|
||||
only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read
|
||||
token, and for the same reason: nothing downstream ever needs the plaintext
|
||||
back. It belongs to the user who created it, and its role can never exceed
|
||||
theirs; see the `api_tokens` note under MongoDB Collections for how that stays
|
||||
true across a demotion rather than only at issuance. Scopes are eight
|
||||
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
|
||||
`workloads`, `settings` — each split into `:read` and `:write`, with `:write`
|
||||
satisfying a `:read` requirement on the same resource so a caller does not have
|
||||
to hold both. Any signed-in member may mint and revoke their **own** tokens —
|
||||
there is no `RequireRole` on `POST /tokens` or `DELETE /tokens/:id` — because
|
||||
`roleRank` already bounds what a token can do to no more than its creator's
|
||||
own role, so a member cannot use a token to reach past themselves. Owner and
|
||||
admin additionally see and revoke every token in the instance — `all=true` on
|
||||
`GET /tokens` is gated by `elevated()` in `api/tokens.go`, and
|
||||
`RevokeAPIToken` in `services/tokens.go` checks the same owner/admin condition
|
||||
before letting a revoke target somebody else's token — neither is a
|
||||
`RequireRole` middleware.
|
||||
The `settings:read`/`settings:write` entries in `routeScopes` govern a
|
||||
**token-authenticated** caller reaching the token endpoints — `RequireScopes`
|
||||
no-ops entirely for a cookie session — so they say nothing about which human
|
||||
role may call these routes with a session; that is `roleRank` and `elevated()`,
|
||||
not the scope map. Expiry is optional per token; `settings.api_token_max_days` caps how
|
||||
far out a new one may be set, and when that cap is set a token requested with
|
||||
no expiry is refused rather than silently capped — the policy governs
|
||||
issuance only and never reaches back to invalidate a token already issued.
|
||||
`RateLimitTokens` holds every token to 600 requests/minute in a Redis fixed
|
||||
window, answering 429 with `Retry-After`; cookie sessions are untouched; it
|
||||
exists so a runaway script cannot take an instance down, not as the general
|
||||
API rate-limiting project some future ticket might build.
|
||||
|
||||
**The UI calls them API keys and lives at `/tokens`, not on `/settings`.**
|
||||
The page is reachable at **every** role, which is the whole reason it is a page:
|
||||
`/settings` is owner|admin throughout, so a card there hid a capability every
|
||||
member has. `settings.api_token_max_days` stays on `/settings` because it is
|
||||
instance policy rather than one person's credentials, and that split is exactly
|
||||
what lets the page be ungated. The label differs from the identifiers on
|
||||
purpose — the collection is `api_tokens`, the prefix is `vt_`, the routes are
|
||||
`/api/tokens`, and renaming a published endpoint to match a nav label would
|
||||
break every script already written against it.
|
||||
|
||||
`server/internal/api/docs/openapi.json` is a **generated, committed** OpenAPI
|
||||
3.1 document — `swag v2` reading `@…` annotations off the handlers — served at
|
||||
`GET /api/openapi.json` and rendered as a reference page by a vendored Scalar
|
||||
bundle at `GET /api/docs`. `server-deploy.yml` regenerates it on every server
|
||||
build and runs `git diff --exit-code` against the committed copy: a handler
|
||||
whose annotation drifted from its code fails CI rather than shipping a
|
||||
reference that lies. Scalar is vendored (`scalar.standalone.js`, served from
|
||||
`GET /api/docs/scalar.js`) rather than pulled from a CDN, because the
|
||||
reference page has to work on an air-gapped install with no outbound access at
|
||||
all — the same requirement licence verification already meets.
|
||||
|
||||
### Marketing site and sitesvc
|
||||
|
||||
`site/` is a separate Next.js app built exactly like `web/` — `output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
|
||||
@@ -436,6 +605,20 @@ The control plane refuses to change an `hq`-sourced user's role or delete it
|
||||
the portal, but the API is the boundary; the UI is a courtesy. There is no local
|
||||
password-change endpoint at all, so there is no competing writer for the hash.
|
||||
|
||||
**A rename moves the host, and the licence does not care.** `PUT
|
||||
/api/instances/:id/name` re-derives the slug from the new name through
|
||||
`provision.RenameSlug` — the same rules that named the instance at creation —
|
||||
and writes the control plane first, because `instances.slug`'s unique index is
|
||||
what settles a race between two accounts reaching for one name. A taken slug is
|
||||
a refusal, not an `acme-2`: creation appends a counter because any free slug
|
||||
will do, and a rename is a request for one specific host. A licence binds the
|
||||
instance UUID, so nothing is reissued and Paddle is not called. The old host
|
||||
keeps resolving for up to 60s (`instancehost.go`'s cache, which admin cannot
|
||||
reach into), and `km_session` is host-only, so the customer signs in again on
|
||||
the new address — the portal says so rather than redirecting them into a login
|
||||
screen with no explanation. The 24h cooldown lives on `admin_instances.renamed_at`
|
||||
because it is admin's policy; staff bypass it and must not write the field.
|
||||
|
||||
---
|
||||
|
||||
## Auth and Orgs
|
||||
@@ -467,6 +650,7 @@ service Vantage {
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
@@ -476,7 +660,8 @@ service Vantage {
|
||||
|
||||
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
|
||||
|
||||
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`.
|
||||
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`, `RefreshWorkloadsCmd`, `ControlWorkloadCmd`,
|
||||
`WorkloadLogsCmd`.
|
||||
|
||||
**`PingCmd` is a liveness beat, and it is not redundant with gRPC keepalive.**
|
||||
The server sends one every 20s on an otherwise idle command stream; the agent
|
||||
@@ -527,6 +712,16 @@ channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:i
|
||||
secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group
|
||||
POST /secrets/:group/reveal · DELETE /secrets/:group/:key
|
||||
console POST /console/connect · GET /console/tunnel (websocket)
|
||||
vulns GET /vulnerabilities · GET /vulnerabilities/summary
|
||||
POST /vulnerabilities/rescan (owner|admin)
|
||||
POST,DELETE /vulnerabilities/:id/accept (owner|admin)
|
||||
GET /servers/:id/vulnerabilities · GET /servers/:id/packages
|
||||
GET /packages/search?name=
|
||||
GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin)
|
||||
workloads GET /workloads · GET /servers/:id/workloads
|
||||
POST /servers/:id/workloads/refresh
|
||||
POST /servers/:id/workloads/:wid/action (owner|admin)
|
||||
GET /servers/:id/workloads/:wid/logs (owner|admin)
|
||||
audit GET /audit
|
||||
agent GET /agent/latest-version
|
||||
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
|
||||
@@ -534,6 +729,8 @@ licence GET /license · POST /license (POST: self-hosted onl
|
||||
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
|
||||
providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id
|
||||
POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin)
|
||||
tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens/:id
|
||||
GET /openapi.json · GET /docs
|
||||
```
|
||||
|
||||
`GET /license` reports `deployment`, and **`POST /license` answers 409 `cloud_managed` when it is `cloud`**. A cloud instance's licence is written by `admin/internal/inject` straight into the database and never through this endpoint, so the refusal cannot break injection — it only stops a customer pasting over a licence they do not own. `web/` hides the paste form and points at the HQ portal instead, but as with `hq`-managed users, the API is the boundary and the UI is the courtesy.
|
||||
@@ -565,11 +762,11 @@ GET /account # account, instances, max_relinks
|
||||
POST /instances # create a cloud instance (Free tier, one Free per account per deployment)
|
||||
POST /instances/:id/renew # Free renewal; refuses outside the renewal window
|
||||
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
|
||||
PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown)
|
||||
POST /instances/link · /instances/:id/relink
|
||||
GET /instances/:id/entitlement
|
||||
GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV
|
||||
POST /instances/self-hosted # create a paid-checkout placeholder (awaiting_link, no licence)
|
||||
POST /instances/:id/claim-link # bind a paid placeholder to the real UUID and issue
|
||||
POST /instances/self-hosted # link (or reuse) the install's real UUID for a paid checkout
|
||||
PUT /instances/:id/entitlement # set desired config; pushes line items to Paddle (owner|admin)
|
||||
POST /billing/portal # mint a Paddle customer-portal URL
|
||||
GET /instances/:id/license · /instances/:id/license/download
|
||||
@@ -591,6 +788,7 @@ Staff-session (`/api/staff`):
|
||||
GET,POST /accounts · GET /accounts/:id # search by name, email, Paddle ID or instance UUID
|
||||
GET,POST /instances · GET /instances/:id # instance + account + licence history + injection state
|
||||
POST /instances/:id/issue · /instances/:id/relink
|
||||
PUT /instances/:id/name # rename any instance, no cooldown
|
||||
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
|
||||
GET,PUT /catalogue
|
||||
GET,PUT /instances/:id/entitlement
|
||||
@@ -603,11 +801,11 @@ GET /health/injection · /health/billing
|
||||
|
||||
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
|
||||
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
|
||||
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. **Self-hosted purchase requires a standing control plane**: the customer pastes their install's real instance ID, `POST /instances/self-hosted` links it (or reuses one this account already owns, which is how Free upgrades to paid in place), and the checkout's `custom_data` names that UUID from the first event — so the webhook issues with no claim step and there is **no self-hosted placeholder**. A licence binds to the install's UUID, so buying before the install exists only ever deferred the same requirement behind a second identity to rewrite. `Placeholder` is now a cloud-only flag; a non-cloud placeholder reaching `handleSubscription` is a pre-change row and fails loudly rather than being guessed at.
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `migrations`
|
||||
|
||||
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
|
||||
|
||||
@@ -622,6 +820,11 @@ Notes that are not obvious from the structs:
|
||||
- `auth_providers.provider_id` is a short random identifier, not the Mongo `_id`: it appears in the callback URL a customer pastes into their IdP, and an `_id` there would publish a database key. `callback_notice` marks a provider migrated from the old single-provider shape, whose redirect URI therefore changed.
|
||||
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
|
||||
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
|
||||
- `server_packages` holds a server's whole package set in **one** document, not one per package. The hash already established that something changed, so a report is a single atomic upsert with no delta logic to get wrong; ~2000 packages is ~150KB, well inside the 16MB limit. `scan_pending` lives on the document rather than in memory so a leader handover cannot lose it.
|
||||
- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable".
|
||||
- `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`.
|
||||
- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance.
|
||||
- `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it — a route added without an entry would otherwise be silently unreachable by every token.
|
||||
|
||||
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
|
||||
|
||||
@@ -714,6 +917,8 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
|
||||
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
|
||||
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
|
||||
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for an air-gapped install, or to avoid the anonymous ghcr rate limit |
|
||||
| `VANTAGE_VULNDB_DISABLED` | no | `true` disables the vulnerability database puller and scan loop entirely. Findings already written are still served, and still shown as stale |
|
||||
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
|
||||
|
||||
**sitesvc** (`deploy/docker-compose.site.yml` only):
|
||||
@@ -817,9 +1022,20 @@ Customer nav is three destinations — Overview, People, Billing. Settings is in
|
||||
| `/steps` | Reusable step library |
|
||||
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
|
||||
| `/secrets`, `/secrets/[group]` | Vault |
|
||||
| `/tokens` | Personal API keys — reachable at **every** role, unlike `/settings` |
|
||||
| `/audit` | Audit log |
|
||||
| `/settings`, `/settings/notifications`, `/settings/license` | Members, OIDC, alerts, retention, ESO token · channels · licence |
|
||||
|
||||
**The sidebar is grouped, and the groups are the nav's structure rather than
|
||||
decoration.** `web/components/Sidebar.tsx` holds `navGroups` — Fleet, Access,
|
||||
Automation, Instance — each rendered with a mono small-caps heading and a
|
||||
hairline rule above it, the first group excepted. Grouping is by what the
|
||||
operator is doing, not by which service answers: SSH keys, vault secrets and
|
||||
API keys sit together under Access because all three are credentials. A group
|
||||
whose every item is `adminOnly` disappears **whole**, heading and rule
|
||||
included, for a member — a labelled section with nothing under it reads as
|
||||
something that failed to load rather than something withheld.
|
||||
|
||||
**`/settings` is one page, not a section.** Members and single sign-on used to
|
||||
live at `/settings/instance` with their own sidebar entry; they are now the
|
||||
**Access** group at the top of `/settings`, above **Monitoring** and
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -8,7 +9,6 @@ import (
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// checkoutOptions serves everything the browser configurator needs to price a
|
||||
@@ -43,36 +44,67 @@ func checkoutOptions(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// createSelfHostedPlaceholder makes an instance row that exists only so a
|
||||
// checkout has something to put in custom_data. It carries no licence and is
|
||||
// flagged Placeholder until the customer pastes their install's real UUID. The
|
||||
// generated id is temporary; linking replaces the identity.
|
||||
func createSelfHostedPlaceholder(c *gin.Context) {
|
||||
// createSelfHostedCheckout prepares a paid self-hosted checkout against the
|
||||
// customer's REAL install UUID, and hands that id back for the checkout's
|
||||
// custom_data.
|
||||
//
|
||||
// A licence binds to the install's UUID, so the buyer must have a control plane
|
||||
// standing before they pay — the same precondition self-hosted Free already has.
|
||||
// That is what removes the placeholder: there is no temporary identity to
|
||||
// rewrite afterwards, the subscription's custom_data names the real instance
|
||||
// from the first event, and the webhook issues with no claim step.
|
||||
//
|
||||
// An id this account already owns is REUSED rather than refused: upgrading a
|
||||
// Free self-hosted install to a paid plan is the same purchase form, and
|
||||
// refusing it would mean the only route to Professional was to unlink first.
|
||||
// A UUID belonging to anyone else is still 409, from the unique index.
|
||||
func createSelfHostedCheckout(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.InstanceID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
instanceID := strings.TrimSpace(body.InstanceID)
|
||||
name := strings.TrimSpace(body.Name)
|
||||
ctx := c.Request.Context()
|
||||
inst := models.Instance{
|
||||
InstanceID: uuid.NewString(),
|
||||
AccountID: s.AccountID,
|
||||
Name: body.Name,
|
||||
Deployment: license.DeploymentSelfHosted,
|
||||
Status: models.StatusAwaitingLink,
|
||||
Placeholder: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
|
||||
var existing models.Instance
|
||||
err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&existing)
|
||||
switch {
|
||||
case err == nil:
|
||||
if existing.Deployment != license.DeploymentSelfHosted {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "that instance is a cloud instance; change its plan from its own page"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": existing.InstanceID})
|
||||
return
|
||||
case !errors.Is(err, mongo.ErrNoDocuments):
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
|
||||
return
|
||||
}
|
||||
inst, err := licensing.LinkInstance(ctx, s.AccountID, instanceID, name)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, licensing.ErrAlreadyLinked) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, IP: c.ClientIP()})
|
||||
Actor: s.Email, Action: "instance.checkout_started", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: "self-hosted", IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
|
||||
}
|
||||
|
||||
@@ -208,80 +240,6 @@ func updateEntitlement(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
|
||||
}
|
||||
|
||||
// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's
|
||||
// real install UUID, then issues.
|
||||
//
|
||||
// :id is the placeholder (generated at checkout, carried in the subscription's
|
||||
// custom_data); the body carries the UUID the install actually reports. The
|
||||
// licence must bind to that real UUID (spec 1 has no unbound licence), so the
|
||||
// placeholder row's identity is rewritten to it and the subscription re-pointed,
|
||||
// then billing issues from the recorded subscription. Linking and claiming stay
|
||||
// one call here because, unlike Free, the payment already happened.
|
||||
func claimPlaceholderLink(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !inst.Placeholder {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// The real UUID must be free across every account — the unique index on
|
||||
// instance_id is the tenant-isolation property, so refuse rather than collide.
|
||||
if n, _ := db.Admin("admin_instances").CountDocuments(ctx,
|
||||
bson.M{"instance_id": body.InstanceID}); n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"})
|
||||
return
|
||||
}
|
||||
|
||||
placeholderID := inst.InstanceID
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"instance_id": body.InstanceID,
|
||||
"status": models.StatusActive,
|
||||
"placeholder": false,
|
||||
},
|
||||
"$addToSet": bson.M{"previous_instance_ids": placeholderID},
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Re-point the subscription rows from the placeholder id to the real UUID so
|
||||
// billing.IssueForInstance finds it, and rewrite Paddle's own copy of
|
||||
// custom_data — written at checkout, it still names the placeholder, and every
|
||||
// later event on this subscription is decoded from it.
|
||||
if err := licensing.RepointSubscriptions(ctx, placeholderID, body.InstanceID, inst.AccountID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil {
|
||||
// The link stuck; issuance did not. The reconciler and a retry recover it,
|
||||
// and the customer is not blocked from linking. Surface it, do not roll back.
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"instance_id": body.InstanceID,
|
||||
"warning": "linked, but licence issuance is pending: " + err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.placeholder_linked",
|
||||
AccountID: inst.AccountID, Target: body.InstanceID,
|
||||
Detail: "from placeholder " + placeholderID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID})
|
||||
}
|
||||
|
||||
// billingPortal mints a Paddle customer-portal URL. The account must already
|
||||
// have a paddle_customer_id, which it learns from its first subscription webhook.
|
||||
func billingPortal(c *gin.Context) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// ownedInstance resolves an instance and confirms the session's account owns it.
|
||||
@@ -538,6 +540,185 @@ func claimFree(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, lic)
|
||||
}
|
||||
|
||||
// renameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// The control plane is written FIRST, because instances.slug carries the unique
|
||||
// index and that index is what actually settles a race between two accounts
|
||||
// reaching for the same name. Admin's own row follows; if that write fails the
|
||||
// control plane is put back, because HQ printing a host that is not the host is
|
||||
// worse than a failed rename.
|
||||
//
|
||||
// No licence is issued and Paddle is not called: a licence binds the instance
|
||||
// UUID, and a rename does not change it.
|
||||
func renameInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
if inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// The unwind and the audit write run on a context detached from the request.
|
||||
// The commonest reason the admin-side write fails at all is the caller
|
||||
// walking away, and an unwind sharing that context fails with it — leaving
|
||||
// the control plane renamed and admin's row not, which is the exact
|
||||
// divergence this handler is arranged to prevent.
|
||||
//
|
||||
// Only the cancellation is detached here; each deadline is derived at its use
|
||||
// site below. A deadline started before the forward work is a deadline the
|
||||
// unwind may never get to use — a control plane slow enough to make the admin
|
||||
// write fail is exactly the one that would have spent it already.
|
||||
detached := context.WithoutCancel(ctx)
|
||||
|
||||
// Claim the cooldown atomically BEFORE the control-plane call. Checking it
|
||||
// and then acting lets two parallel PUTs both pass the check and then
|
||||
// interleave their two-database writes, which ends with the two databases
|
||||
// disagreeing about the host — a worse outcome than either rename losing.
|
||||
// The conditional update IS the cooldown; there is no second reading of it.
|
||||
now := time.Now().UTC()
|
||||
var claimed models.Instance
|
||||
err := db.Admin("admin_instances").FindOneAndUpdate(ctx,
|
||||
bson.M{
|
||||
"instance_id": inst.InstanceID,
|
||||
"account_id": inst.AccountID,
|
||||
"$or": []bson.M{
|
||||
{"renamed_at": bson.M{"$exists": false}},
|
||||
{"renamed_at": bson.M{"$lte": now.Add(-models.RenameCooldown)}},
|
||||
},
|
||||
},
|
||||
bson.M{"$set": bson.M{"renamed_at": now}}).Decode(&claimed)
|
||||
if err != nil {
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
log.Printf("renameInstance: claiming the cooldown on %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
// No match means the cooldown is live or the row has gone; only a
|
||||
// re-read tells those apart, and they are different answers.
|
||||
var cur models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "account_id": inst.AccountID}).Decode(&cur); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if cur.RenamedAt != nil {
|
||||
until := cur.RenamedAt.Add(models.RenameCooldown)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
|
||||
"retry_after": until,
|
||||
})
|
||||
return
|
||||
}
|
||||
// The row is here and its cooldown is spent, yet the claim matched
|
||||
// nothing: it changed under us. Nothing has been written, so refuse
|
||||
// rather than guess which way.
|
||||
log.Printf("renameInstance: cooldown claim on %s matched nothing against an eligible row", inst.InstanceID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
// releaseClaim puts renamed_at back to whatever the claim overwrote — the
|
||||
// previous instant, or absent when there was none. Every failure past the
|
||||
// claim owes the customer their rename back.
|
||||
releaseClaim := func(after string) {
|
||||
undo := bson.M{"$unset": bson.M{"renamed_at": ""}}
|
||||
if claimed.RenamedAt != nil {
|
||||
undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}}
|
||||
}
|
||||
rcCtx, cancel := context.WithTimeout(detached, 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(rcCtx,
|
||||
bson.M{"instance_id": inst.InstanceID}, undo); err != nil {
|
||||
log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err)
|
||||
}
|
||||
}
|
||||
|
||||
renamed, prevName, prevSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
releaseClaim("a taken slug")
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
releaseClaim("a rejected name")
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
releaseClaim("a failed control-plane rename")
|
||||
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
// A matched count of zero is a silent version of the same failure: the
|
||||
// control plane moved and admin's row did not.
|
||||
res, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{"name": renamed.Name, "slug": renamed.Slug}})
|
||||
if err == nil && res.MatchedCount == 0 {
|
||||
err = errors.New("admin_instances row matched nothing")
|
||||
}
|
||||
if err != nil {
|
||||
// The control plane's own previous values, not admin's copy: admin's may
|
||||
// be stale, and its slug is omitempty.
|
||||
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
|
||||
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
rbCancel()
|
||||
releaseClaim("a failed record write")
|
||||
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
if renamed.Slug == prevSlug {
|
||||
// The cooldown exists because a rename moves the DNS host; a cosmetic
|
||||
// edit that derives to the same slug moves nothing, so it should not
|
||||
// spend one. The claim is already written by this point — releasing it
|
||||
// is how that is expressed now the check is atomic.
|
||||
releaseClaim("a rename that did not move the host")
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
audit.Write(auCtx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()})
|
||||
auCancel()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance_id": inst.InstanceID,
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
// The same builder the licence emails use, rather than a second opinion
|
||||
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
|
||||
"login_url": loginURLFor(renamed.Slug),
|
||||
})
|
||||
}
|
||||
|
||||
// deliver sends a freshly issued licence where it needs to go. Cloud instances
|
||||
// are injected; self-hosted customers are emailed and can download.
|
||||
//
|
||||
|
||||
@@ -75,11 +75,18 @@ func Routes(cfg config.Config) http.Handler {
|
||||
cust.POST("/instances/:id/claim-free",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
claimFree)
|
||||
// Renaming moves the instance's DNS host, so it is owner-or-admin like
|
||||
// every other instance mutation. Cloud only; the handler refuses the rest.
|
||||
cust.PUT("/instances/:id/name",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
renameInstance)
|
||||
cust.GET("/instances/:id/entitlement", getEntitlement)
|
||||
cust.GET("/checkout/options", checkoutOptions)
|
||||
// Paid self-hosted: links (or reuses) the customer's real install UUID so
|
||||
// the checkout can name it. There is no placeholder and no claim step.
|
||||
cust.POST("/instances/self-hosted",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createSelfHostedPlaceholder)
|
||||
createSelfHostedCheckout)
|
||||
// Paid cloud: provisions a real instance the paid webhook then licenses.
|
||||
cust.POST("/instances/cloud",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
@@ -88,9 +95,6 @@ func Routes(cfg config.Config) http.Handler {
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateEntitlement)
|
||||
cust.POST("/billing/portal", billingPortal)
|
||||
cust.POST("/instances/:id/claim-link",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
claimPlaceholderLink)
|
||||
cust.GET("/instances/:id/license", getInstanceLicense)
|
||||
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
|
||||
cust.GET("/instances/:id/members", listInstanceMembers)
|
||||
@@ -119,6 +123,7 @@ func Routes(cfg config.Config) http.Handler {
|
||||
staff.GET("/subscriptions", staffListSubscriptions)
|
||||
staff.POST("/instances/:id/issue", staffIssue)
|
||||
staff.POST("/instances/:id/relink", staffRelink)
|
||||
staff.PUT("/instances/:id/name", staffRenameInstance)
|
||||
staff.GET("/licenses", staffListLicenses)
|
||||
staff.GET("/plans", staffListPlans)
|
||||
// Plans are keyed on the pair now, so the path is too. A single :tier
|
||||
|
||||
+114
-3
@@ -1,18 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -380,9 +385,12 @@ func staffListLicenses(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, lics)
|
||||
}
|
||||
|
||||
// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked
|
||||
// placeholders, so a customer who paid and got nothing is visible rather than
|
||||
// stuck in a support queue.
|
||||
// staffBillingHealth surfaces webhook handlers that failed and placeholders
|
||||
// still awaiting their instance, so a customer who paid and got nothing is
|
||||
// visible rather than stuck in a support queue.
|
||||
//
|
||||
// Placeholders are a cloud-only path now; any self-hosted row still listed here
|
||||
// predates the checkout change and needs issuing by hand.
|
||||
func staffBillingHealth(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
failed := []models.PaddleEvent{}
|
||||
@@ -615,3 +623,106 @@ func staffCreateAccountUser(c *gin.Context) {
|
||||
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
}
|
||||
|
||||
// staffRenameInstance renames any instance, with no cooldown.
|
||||
//
|
||||
// It does NOT write renamed_at: a staff rename must not start the customer's
|
||||
// 24h clock, or fixing a name for someone locks them out of fixing it further.
|
||||
//
|
||||
// On self-hosted it changes admin's label only. There is no control-plane row to
|
||||
// write — the install is the customer's — and no slug, because self-hosted has
|
||||
// no tenant subdomain.
|
||||
//
|
||||
// A cloud placeholder is refused outright rather than relabelled: it has no
|
||||
// control-plane row yet, so a label-only rename here would be a name that the
|
||||
// instance never gets when provisioning finally derives its slug from the
|
||||
// checkout's name. The customer endpoint refuses it for the same reason.
|
||||
func staffRenameInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if inst.Deployment == license.DeploymentCloud && inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
// The unwind and the audit write must survive the request being cancelled:
|
||||
// an unwind on a dead context leaves the two databases disagreeing, which is
|
||||
// the failure the unwind exists for.
|
||||
//
|
||||
// Only the cancellation is detached here; each deadline is derived at its use
|
||||
// site below. A deadline started before the forward work is a deadline the
|
||||
// unwind may never get to use — a control plane slow enough to make the admin
|
||||
// write fail is exactly the one that would have spent it already.
|
||||
detached := context.WithoutCancel(ctx)
|
||||
|
||||
set := bson.M{"name": name}
|
||||
slug := inst.Slug
|
||||
cloud := inst.Deployment == license.DeploymentCloud
|
||||
// The control plane's own previous values, not admin's copy: admin's may be
|
||||
// stale, and its slug is omitempty, so unwinding from it can write an empty
|
||||
// slug into instances.
|
||||
prevName, prevSlug := inst.Name, inst.Slug
|
||||
|
||||
if cloud {
|
||||
renamed, pName, pSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
prevName, prevSlug = pName, pSlug
|
||||
slug = renamed.Slug
|
||||
set["slug"] = renamed.Slug
|
||||
}
|
||||
|
||||
// A matched count of zero is the same failure quietly: the control plane
|
||||
// moved and admin's row did not.
|
||||
res, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set})
|
||||
if err == nil && res.MatchedCount == 0 {
|
||||
err = errors.New("admin_instances row matched nothing")
|
||||
}
|
||||
if err != nil {
|
||||
if cloud {
|
||||
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
|
||||
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
rbCancel()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
audit.Write(auCtx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
|
||||
Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()})
|
||||
auCancel()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
|
||||
}
|
||||
|
||||
@@ -69,12 +69,12 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
|
||||
}
|
||||
|
||||
// Resolve BEFORE recording. A self-hosted subscription's custom_data is
|
||||
// written at checkout and names the placeholder; the claim rewrote the
|
||||
// instance's identity to the install's real UUID and patched Paddle, but that
|
||||
// patch is best-effort and any event already in flight still carries the old
|
||||
// id. Writing it straight through would revert the linked subscription row and
|
||||
// then fail to find the instance, wedging every renewal.
|
||||
// Resolve BEFORE recording. custom_data names whatever id the checkout was
|
||||
// opened against, and a relink since then has rewritten the instance's
|
||||
// identity and patched Paddle — but that patch is best-effort and any event
|
||||
// already in flight still carries the old id. Writing it straight through
|
||||
// would revert the subscription row and then fail to find the instance,
|
||||
// wedging every renewal.
|
||||
instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscription %s names unknown instance %s: %w",
|
||||
@@ -102,15 +102,20 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
|
||||
}
|
||||
|
||||
// Placeholders are the payment-first path: the instance does not exist until
|
||||
// this confirmed-payment event. A cloud placeholder is provisioned here and
|
||||
// then issued (first term). A self-hosted placeholder has no UUID to bind to
|
||||
// until the customer pastes their install's — its subscription is recorded and
|
||||
// the link endpoint issues later.
|
||||
// A cloud placeholder is the payment-first path: the instance does not exist
|
||||
// until this confirmed-payment event, so it is provisioned here and then
|
||||
// issued (first term). Self-hosted has no placeholder — its checkout named
|
||||
// the install's real UUID — so it falls straight through to issuance.
|
||||
// An instance with no licence yet is a first purchase, not a change of plan.
|
||||
// Self-hosted reaches that state through an ordinary link, so the placeholder
|
||||
// flag no longer answers this on its own.
|
||||
reason := models.ReasonEntitlementChange
|
||||
if inst.CurrentLicense == "" {
|
||||
reason = models.ReasonNew
|
||||
}
|
||||
if inst.Placeholder {
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
return nil
|
||||
return fmt.Errorf("instance %s is a non-cloud placeholder, which no longer exists", inst.InstanceID)
|
||||
}
|
||||
provisioned, err := completeCloudPlaceholder(ctx, &inst)
|
||||
if err != nil {
|
||||
@@ -124,8 +129,8 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
}
|
||||
|
||||
// resolveInstance finds the instance a webhook's custom_data names, following the
|
||||
// identity trail when the id is one a placeholder claim or a relink has since
|
||||
// replaced. It returns the instance's CURRENT id, which is the only id anything
|
||||
// identity trail when the id is one a relink or a cloud placeholder's
|
||||
// provisioning has since replaced. It returns the instance's CURRENT id, which is the only id anything
|
||||
// else should be written against.
|
||||
func resolveInstance(ctx context.Context, customDataID string) (string, models.Instance, error) {
|
||||
var inst models.Instance
|
||||
@@ -245,30 +250,6 @@ func handleCustomerUpdated(ctx context.Context, ev Event) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IssueForInstance issues from an instance's recorded subscription. Called when
|
||||
// a self-hosted customer finally links a placeholder they have already paid for.
|
||||
func IssueForInstance(ctx context.Context, instanceID string) error {
|
||||
var sub models.Subscription
|
||||
if err := db.Admin("subscriptions").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
|
||||
return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
|
||||
}
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]catalogue.Item, 0, len(sub.Items))
|
||||
for _, it := range sub.Items {
|
||||
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
|
||||
}
|
||||
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
|
||||
}
|
||||
|
||||
func toSubItems(items []catalogue.Item) []models.SubItem {
|
||||
out := make([]models.SubItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
@@ -308,7 +289,7 @@ func billingEmailFor(ctx context.Context, accountID string) string {
|
||||
|
||||
// instanceNameFor is a best-effort display name for an email subject.
|
||||
func instanceNameFor(ctx context.Context, instanceID string) string {
|
||||
// Alias-aware: a cancellation can name a placeholder id, and "your instance"
|
||||
// Alias-aware: a cancellation can name an id a relink has replaced, and "your instance"
|
||||
// in place of the name the customer chose reads like the wrong email.
|
||||
_, inst, err := resolveInstance(ctx, instanceID)
|
||||
if err != nil || inst.Name == "" {
|
||||
|
||||
@@ -202,3 +202,23 @@ func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User,
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// RenameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// It writes `instances` and nothing else, so admin's control-plane write
|
||||
// boundary is unchanged. It issues no licence: a licence binds the instance
|
||||
// UUID, which a rename never touches.
|
||||
//
|
||||
// The previous name and slug come back with the result because they are what an
|
||||
// unwind must restore — admin's own copy can be stale, or slugless.
|
||||
func RenameInstance(ctx context.Context, instanceID, name string) (inst *sharedmodels.Instance, prevName, prevSlug string, err error) {
|
||||
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
|
||||
// caller unwinding a rename whose admin-side write failed. Leaving the two
|
||||
// databases disagreeing would have HQ print a host that is not the host.
|
||||
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
|
||||
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*mod
|
||||
//
|
||||
// The local rewrite is returned as an error — issuance reads the subscription
|
||||
// back, so a half-moved row is worth failing on. The Paddle patch only logs: the
|
||||
// customer must not be blocked from linking or relinking by an outbound API
|
||||
// customer must not be blocked from relinking by an outbound API
|
||||
// failure, and the caller has already recorded the old id in
|
||||
// previous_instance_ids, which is what makes the webhook path correct whether or
|
||||
// not the patch lands.
|
||||
@@ -151,9 +151,9 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m
|
||||
return nil, fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
|
||||
// A relink rewrites the instance's identity exactly as a placeholder claim
|
||||
// does, so the same two things have to follow it: the subscription rows that
|
||||
// named the old id, and Paddle's own copy of custom_data. Without this a
|
||||
// A relink rewrites the instance's identity, so two things have to follow it:
|
||||
// the subscription rows that named the old id, and Paddle's own copy of
|
||||
// custom_data. Without this a
|
||||
// renewal after a relink cannot find its instance and the term never extends.
|
||||
if err := RepointSubscriptions(ctx, oldID, newID, accountID); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -182,67 +182,6 @@ func runOnce(ctx context.Context) {
|
||||
if err := Run(runCtx); err != nil {
|
||||
log.Printf("lifecycle: %v", err)
|
||||
}
|
||||
sweepAwaitingLink(runCtx)
|
||||
}
|
||||
|
||||
// Awaiting-link reminder keys.
|
||||
const (
|
||||
noticeLink24 = "link_24"
|
||||
noticeLink72 = "link_72"
|
||||
)
|
||||
|
||||
// sweepAwaitingLink chases self-hosted instances that were paid for but never
|
||||
// linked: the subscription exists, the instance is still a placeholder. It
|
||||
// emails a reminder at 24h and again at 72h. The staff dashboard already flags
|
||||
// 48h; this is the active chasing on top of that. It never issues or deletes.
|
||||
func sweepAwaitingLink(ctx context.Context) {
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentSelfHosted,
|
||||
"placeholder": true,
|
||||
"status": models.StatusAwaitingLink,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var instances []models.Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, inst := range instances {
|
||||
// Only chase placeholders a customer has actually paid for.
|
||||
n, err := db.Admin("subscriptions").CountDocuments(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive})
|
||||
if err != nil || n == 0 {
|
||||
continue
|
||||
}
|
||||
if !mail.Enabled() {
|
||||
continue
|
||||
}
|
||||
to := accountEmail(ctx, inst.AccountID)
|
||||
if to == "" {
|
||||
continue
|
||||
}
|
||||
age := now.Sub(inst.CreatedAt)
|
||||
var due string
|
||||
if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink72) {
|
||||
due = noticeLink72
|
||||
} else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink24) {
|
||||
due = noticeLink24
|
||||
}
|
||||
if due == "" {
|
||||
continue
|
||||
}
|
||||
if err := mail.Default.SendLinkReminder(to, inst.Name); err != nil {
|
||||
log.Printf("lifecycle: link reminder %s for %s: %v", due, inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
|
||||
log.Printf("lifecycle: record link notice %s for %s: %v", due, inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func accountEmail(ctx context.Context, accountID string) string {
|
||||
|
||||
@@ -67,7 +67,8 @@ func (r CatalogueRow) Priced(env string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
|
||||
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key.
|
||||
//
|
||||
// The two Free plans get no rows at all, and that absence is what keeps Free
|
||||
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
|
||||
@@ -84,6 +85,7 @@ func SeedCatalogue(ctx context.Context) error {
|
||||
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
|
||||
}
|
||||
for _, r := range rows {
|
||||
filter := bson.M{
|
||||
|
||||
@@ -111,6 +111,15 @@ const GracePeriod = 3 * 24 * time.Hour
|
||||
// second mechanism.
|
||||
const RenewWindow = 7 * 24 * time.Hour
|
||||
|
||||
// RenameCooldown is how long a customer must wait between renames of one
|
||||
// instance.
|
||||
//
|
||||
// A rename moves the instance's DNS host and invalidates every saved link to it,
|
||||
// so this exists to make that a considered act rather than a slider. Staff are
|
||||
// not subject to it: a support conversation about a name is already a human
|
||||
// deciding.
|
||||
const RenameCooldown = 24 * time.Hour
|
||||
|
||||
type Account struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
@@ -137,20 +146,26 @@ type Instance struct {
|
||||
Status string `bson:"status" json:"status"`
|
||||
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
|
||||
RelinkCount int `bson:"relink_count" json:"relink_count"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
// RenamedAt is when this instance last changed name, and backs the customer
|
||||
// rename cooldown. It is a pointer because absent means "never renamed"; a
|
||||
// zero time.Time would read as year 1 — an inert cooldown, but only by
|
||||
// accident. Staff renames deliberately leave it alone.
|
||||
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
// NoticesSent holds the lifecycle notice keys already emailed for the
|
||||
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
|
||||
// clears it, so the next term starts the sequence again. It is what stops a
|
||||
// restart re-sending a notice.
|
||||
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
|
||||
// Placeholder is true while a self-hosted instance row exists only so a
|
||||
// checkout has something to attach custom_data to, before the customer has
|
||||
// pasted their install's real UUID. Cleared when the instance is linked.
|
||||
// Placeholder is true while a paid CLOUD instance row exists only so a
|
||||
// checkout has something to attach custom_data to, before the confirmed
|
||||
// payment provisions it. Cleared once provisioned. Self-hosted has no
|
||||
// placeholder: its checkout names the install's real UUID.
|
||||
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
|
||||
// PreviousInstanceIDs is every id this row has carried before its current one.
|
||||
// A self-hosted row's identity is rewritten twice over its life — once when a
|
||||
// paid placeholder is claimed, and again on each relink to a rebuilt server —
|
||||
// and Paddle keeps its own copy of custom_data written at checkout. That copy
|
||||
// A self-hosted row's identity is rewritten on each relink to a rebuilt
|
||||
// server, and Paddle keeps its own copy of custom_data written at checkout.
|
||||
// That copy
|
||||
// is patched on each rewrite, but the patch is best-effort and any event
|
||||
// already in flight still names an old id, so this is what lets a webhook
|
||||
// resolve to the right instance instead of erroring as unknown.
|
||||
|
||||
@@ -26,8 +26,8 @@ type Client interface {
|
||||
// customer changes their server count or features on an existing plan.
|
||||
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
|
||||
// UpdateSubscriptionCustomData replaces a subscription's custom_data. Used
|
||||
// when a self-hosted placeholder is claimed: the checkout attached the
|
||||
// placeholder id, and every later webhook must name the real install UUID.
|
||||
// when a self-hosted instance is relinked to a rebuilt server: the checkout
|
||||
// attached the old id, and every later webhook must name the new one.
|
||||
UpdateSubscriptionCustomData(ctx context.Context, paddleSubscriptionID string, data map[string]string) error
|
||||
// PortalSession returns a customer-portal URL for managing billing.
|
||||
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
|
||||
|
||||
@@ -6,7 +6,10 @@ import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { ManageBillingButton } from "@/components/ManageBillingButton";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { TermSpark } from "@/components/TermBar";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { formatDate, licenceState } from "@/lib/format";
|
||||
|
||||
export default function BillingPage() {
|
||||
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
|
||||
@@ -22,6 +25,21 @@ export default function BillingPage() {
|
||||
// difference between "professional · annual" and knowing which install that is.
|
||||
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
|
||||
|
||||
/*
|
||||
* A subscription reports when the period ends but not when it began, so the
|
||||
* start is derived from the term. Only the two terms we actually sell are
|
||||
* handled — anything else returns null and the row falls back to the date
|
||||
* alone, because a bar drawn from a guessed span is worse than no bar.
|
||||
*/
|
||||
const periodStart = (end: string, term: string): string | null => {
|
||||
const months = /ann|year/i.test(term) ? 12 : /month/i.test(term) ? 1 : 0;
|
||||
if (!months) return null;
|
||||
const d = new Date(end);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setMonth(d.getMonth() - months);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
@@ -56,34 +74,53 @@ export default function BillingPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">You have no subscriptions. Cloud instances and self-hosted licences are both bought from the pricing page.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
<th className="px-4 py-2.5 font-normal">Instance</th>
|
||||
<th className="px-4 py-2.5 font-normal">Plan</th>
|
||||
<th className="px-4 py-2.5 font-normal">Term</th>
|
||||
<th className="px-4 py-2.5 font-normal">Status</th>
|
||||
<th className="px-4 py-2.5 font-normal">Renews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
|
||||
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{s.term}</td>
|
||||
<td className="px-4 py-3">{s.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">{formatDate(s.current_period_end)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Panel title="Subscriptions" meta={rows.length ? `${rows.length}` : undefined} bodyless>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No subscriptions yet."
|
||||
body="Cloud instances and self-hosted licences are both bought from the plan page, and each one bills separately."
|
||||
/>
|
||||
) : (
|
||||
<Table stack>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Instance</TH>
|
||||
<TH>Plan</TH>
|
||||
<TH>Billing</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Renews</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((s) => {
|
||||
const start = periodStart(s.current_period_end, s.term);
|
||||
const name = nameFor(s.instance_id);
|
||||
return (
|
||||
<TR key={s.subscription_id}>
|
||||
<TD label="Instance">
|
||||
{name ?? <span className="text-ink-3">Not linked yet</span>}
|
||||
{name && <Sub>{s.instance_id?.slice(0, 8)}</Sub>}
|
||||
</TD>
|
||||
<TD label="Plan">{s.tier.replace("_", " ")}</TD>
|
||||
<TD label="Billing" className="text-ink-2">
|
||||
{s.term}
|
||||
</TD>
|
||||
<TD label="Status" className="text-ink-2">
|
||||
{s.status}
|
||||
</TD>
|
||||
<TD label="Renews">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{start && <TermSpark issuedAt={start} expiresAt={s.current_period_end} state={licenceState(s.current_period_end, true)} />}
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{formatDate(s.current_period_end)}</span>
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,64 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { API_BASE, ApiError, NotConnected, api, type License } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { LicenceDelivery } from "@/components/LicenceDelivery";
|
||||
import { MembersPanel } from "@/components/MembersPanel";
|
||||
import { RelinkPanel } from "@/components/RelinkPanel";
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { TermBar } from "@/components/TermBar";
|
||||
import { EmptyState, Note, Panel } from "@/components/Panel";
|
||||
import { PageFrame, RailCard } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/** One key/value row. The key is the same keyed idiom as everywhere else. */
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<dt className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{label}</dt>
|
||||
<dd className="m-0 text-[0.88rem] tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Every feature the product sells, granted or not.
|
||||
*
|
||||
* Listing only what is included answers "what do I have" but not "what am I
|
||||
* missing", which is the question someone on this screen is actually weighing
|
||||
* before they click Change plan. The absent ones are struck through rather than
|
||||
* omitted, so the comparison is on the page instead of in another tab.
|
||||
*/
|
||||
function Features({ granted }: { granted: string[] }) {
|
||||
const all = Object.keys(FEATURE_LABEL);
|
||||
// Anything the licence carries that this build does not know about is still
|
||||
// shown — the map degrades to the raw key, which is ugly but never wrong.
|
||||
const extras = granted.filter((f) => !all.includes(f));
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Features</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[...all, ...extras].map((f) => {
|
||||
const on = granted.includes(f);
|
||||
return (
|
||||
<span key={f} title={featureDesc(f) || undefined} className={on ? "rounded-sm border border-rule px-2 py-0.5 text-[0.78rem] text-ink-2" : "rounded-sm border border-rule-soft px-2 py-0.5 text-[0.78rem] text-ink-3 line-through decoration-ink-3/60"}>
|
||||
{featureLabel(f)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InstancePage() {
|
||||
const id = String(useParams().id);
|
||||
@@ -19,6 +67,10 @@ export default function InstancePage() {
|
||||
const qc = useQueryClient();
|
||||
const [relinkError, setRelinkError] = useState<string | undefined>();
|
||||
|
||||
// useSession is the app's one way to ask who the caller is — it shares the
|
||||
// ["me"] query, so this adds no request.
|
||||
const { session } = useSession();
|
||||
|
||||
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
const licence = useQuery({
|
||||
queryKey: ["license", id],
|
||||
@@ -28,12 +80,11 @@ export default function InstancePage() {
|
||||
|
||||
const relink = useMutation({
|
||||
mutationFn: (newId: string) => api.relink(id, newId),
|
||||
onSuccess: (lic) => {
|
||||
onSuccess: (lic: License) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
router.replace(`/instances/${lic.instance_id}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
|
||||
onError: (err) => setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
|
||||
});
|
||||
|
||||
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
@@ -50,117 +101,176 @@ export default function InstancePage() {
|
||||
const lic = licence.data;
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
const cloud = instance.deployment === "cloud";
|
||||
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
|
||||
const maxRelinks = account.data?.max_relinks ?? 3;
|
||||
const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title={instance.name || "Unnamed instance"}
|
||||
subtitle={`${cloud ? "Cloud" : "Self-hosted"}${
|
||||
instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""
|
||||
} · created ${formatDate(instance.created_at)}`}
|
||||
record={[
|
||||
{ key: "Instance", value: instance.instance_id, copy: true },
|
||||
...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : []),
|
||||
]}
|
||||
subtitle={`${cloud ? "Cloud" : "Self-hosted"} instance${instance.tier ? ` on ${instance.tier.replace("_", " ")}` : ""} · created ${formatDate(instance.created_at)}`}
|
||||
/*
|
||||
* The two things this screen is for, in the header rather than
|
||||
* hunted for further down. Download is self-hosted only: a cloud
|
||||
* licence is injected into the control plane directly and there
|
||||
* is nothing for the customer to do with the file.
|
||||
*/
|
||||
actions={
|
||||
<>
|
||||
{lic && !cloud && (
|
||||
<LinkButton variant="line" external href={api.licenseBlobUrl(instance.instance_id)}>
|
||||
Download licence
|
||||
</LinkButton>
|
||||
)}
|
||||
{lic && <LinkButton href="/purchase">Renew licence</LinkButton>}
|
||||
</>
|
||||
}
|
||||
record={[{ key: "Instance", value: instance.instance_id, copy: true }, ...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : [])]}
|
||||
status={<StatePill state={state} />}
|
||||
/>
|
||||
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
<RailCard title="Licence">
|
||||
{lic ? (
|
||||
<RailFacts
|
||||
rows={[
|
||||
{ label: "Tier", value: lic.tier.replace("_", " ") },
|
||||
{ label: "Issued", value: formatDate(lic.issued_at) },
|
||||
{ label: "Expires", value: formatDate(lic.expires_at) },
|
||||
{ label: "Reason", value: lic.reason },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
No licence issued yet.
|
||||
</p>
|
||||
)}
|
||||
host ? (
|
||||
<RailCard title="Console">
|
||||
<p className="text-[0.82rem] text-ink-2">Servers, workflows and monitors live in the instance itself.</p>
|
||||
<a href={`https://${host}`} className="inline-flex items-center justify-center gap-2 rounded border border-rule px-3 py-2 text-[0.84rem] font-semibold text-ink no-underline hover:border-accent hover:text-accent">
|
||||
Open {instance.name || "instance"} →
|
||||
</a>
|
||||
<p className="font-mono text-[0.72rem] text-ink-3">{host}</p>
|
||||
</RailCard>
|
||||
|
||||
{lic && (
|
||||
<RailCard title="Included">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Servers",
|
||||
value: limitLabel(lic.limits.max_servers),
|
||||
},
|
||||
{
|
||||
label: "Secret groups",
|
||||
value: limitLabel(lic.limits.max_secret_groups),
|
||||
},
|
||||
{
|
||||
label: "Channels",
|
||||
value: limitLabel(lic.limits.max_channels),
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
value: lic.features.join(", ") || "none",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
{!cloud && (
|
||||
<RailCard title="Moves">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Relinks used",
|
||||
value: `${instance.relink_count} of ${
|
||||
account.data?.max_relinks ?? 3
|
||||
}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Moving a licence to a different install counts as one.
|
||||
</p>
|
||||
</RailCard>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{cloud ? (
|
||||
<MembersPanel instanceId={instance.instance_id} />
|
||||
) : (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
Users for this install are managed inside it, in Settings → Instance. We do
|
||||
not have access to your own deployment.
|
||||
</p>
|
||||
{/*
|
||||
* The term leads. This screen is about one licence, and the rail
|
||||
* carried its issue and expiry dates as two lines of text —
|
||||
* which is the arithmetic this bar does for the reader.
|
||||
*/}
|
||||
{lic && (
|
||||
<Panel title="Licence" meta={`${lic.tier.replace("_", " ")} · ${cloud ? "Cloud" : "Self-hosted"}`}>
|
||||
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
|
||||
|
||||
{state === "warn" && <Note tone="warn">Inside 14 days of expiry. Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.</Note>}
|
||||
{state === "expired" && <Note tone="expired">A lapsed licence does not stop the control plane: agents carry on reporting and your servers keep their keys. It stops accepting changes, so nothing new can be deployed until this is renewed.</Note>}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{lic && !cloud && (
|
||||
<>
|
||||
<LicenceDelivery
|
||||
instanceId={instance.instance_id}
|
||||
blob={lic.blob ?? ""}
|
||||
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
|
||||
{/*
|
||||
* What the licence grants, on the screen about that licence.
|
||||
* These were four rows in a 320px rail card, which is where
|
||||
* facts go when nobody has decided they matter.
|
||||
*/}
|
||||
{lic && (
|
||||
<Panel
|
||||
title="Included"
|
||||
/* A panel-header action is a quiet link, not a second
|
||||
full-size button competing with the header's Renew. */
|
||||
actions={
|
||||
<Link href="/purchase" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
|
||||
Change plan →
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-x-8 gap-y-2.5 sm:grid-cols-2">
|
||||
<dl className="grid content-start gap-2.5">
|
||||
<Row label="Servers" value={limitLabel(lic.limits.max_servers)} />
|
||||
<Row label="Monitors" value={limitLabel(lic.limits.max_monitors)} />
|
||||
<Row label="Secret groups" value={limitLabel(lic.limits.max_secret_groups)} />
|
||||
</dl>
|
||||
<dl className="grid content-start gap-2.5">
|
||||
<Row label="Channels" value={limitLabel(lic.limits.max_channels)} />
|
||||
<Row label="Audit history" value={`${limitLabel(lic.limits.audit_retention_days)} days`} />
|
||||
<Row label="Issued for" value={lic.reason.replace("_", " ")} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<Features granted={lic.features} />
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* On a self-hosted instance the licence is the errand: someone
|
||||
* opens this page to fetch the blob and paste it. It sits
|
||||
* directly under the term, above the panels that only explain
|
||||
* things.
|
||||
*/}
|
||||
{lic && !cloud && <LicenceDelivery instanceId={instance.instance_id} blob={lic.blob ?? ""} downloadUrl={api.licenseBlobUrl(instance.instance_id)} />}
|
||||
|
||||
{cloud && <MembersPanel instanceId={instance.instance_id} />}
|
||||
|
||||
{/*
|
||||
* Address rather than "Rename": the panel is about where this
|
||||
* instance lives, and the rename is how you change it. Cloud
|
||||
* only — a self-hosted install has no tenant subdomain for us to
|
||||
* move.
|
||||
*/}
|
||||
{cloud && mayRename && (
|
||||
<Panel title="Address" meta={host ?? undefined}>
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
|
||||
one, so saved links and bookmarks to it stop working.
|
||||
</p>
|
||||
{/*
|
||||
* Keyed on the instance: this element stays mounted
|
||||
* across a navigation between two instance pages, so
|
||||
* without a key the success note and the typed name
|
||||
* from one instance surface on the next.
|
||||
*/}
|
||||
<RenamePanel
|
||||
key={instance.instance_id}
|
||||
movesHost
|
||||
currentName={instance.name}
|
||||
currentSlug={instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.renameInstance(instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
<RelinkPanel
|
||||
instanceId={instance.instance_id}
|
||||
used={instance.relink_count}
|
||||
max={account.data?.max_relinks ?? 3}
|
||||
error={relinkError}
|
||||
onRelink={(newId) => relink.mutate(newId)}
|
||||
/>
|
||||
</>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* "Moves" rather than "Relinks": the count is rationed, so the
|
||||
* headline is how many are left, and the panel explains what
|
||||
* spends one. Cloud instances cannot move — we own the host —
|
||||
* so the panel is absent rather than present and refusing.
|
||||
*/}
|
||||
{!cloud && (
|
||||
<Panel title="Moves" meta={`${Math.max(0, maxRelinks - instance.relink_count)} of ${maxRelinks} left`}>
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
A licence binds to one install. Rebuilding the host, or moving to different hardware, needs a replacement licence bound to the new ID —
|
||||
that is a move, and it covers the rest of your current term.
|
||||
</p>
|
||||
<RelinkPanel instanceId={instance.instance_id} used={instance.relink_count} max={maxRelinks} error={relinkError} onRelink={(newId) => relink.mutate(newId)} />
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* A panel holding one sentence has not decided what it is for.
|
||||
* For a self-hosted install the useful content is not "we don't
|
||||
* do this" but where the thing they came looking for actually
|
||||
* lives — and why the people on their HQ account are not it.
|
||||
*/}
|
||||
{!cloud && (
|
||||
<Panel title="Who can sign in" meta="Managed in your install">
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
You run this deployment, so its users live inside it rather than here. Add and remove them in the instance’s own settings.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
People on your Vantage HQ account can see billing and this licence. That is separate from who can sign in to the instance, and granting
|
||||
one never grants the other.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!lic && (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
No licence has been issued for this instance yet.
|
||||
</p>
|
||||
<Panel bodyless>
|
||||
<EmptyState title="No licence issued yet." body="A licence binds to one install, so it is issued once this instance is linked to the ID its install reports." action={<LinkButton href="/purchase">Get a licence</LinkButton>} />
|
||||
</Panel>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function LinkForm({
|
||||
onLinked,
|
||||
claimId,
|
||||
}: {
|
||||
onLinked: (instanceId: string) => void;
|
||||
// The PAID placeholder awaiting its real install UUID: claim it in place. The
|
||||
// name was chosen at checkout, so it is not asked for again. Self-hosted Free
|
||||
// is created on the purchase page instead, not here.
|
||||
claimId: string;
|
||||
}) {
|
||||
const [id, setId] = useState("");
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = id.trim();
|
||||
|
||||
// Checked here so a typo costs nothing and the message is instant.
|
||||
if (!UUID_RE.test(value)) {
|
||||
setError(
|
||||
"That does not look like an instance ID. It should look like the example below.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const inst = await api.claimLink(claimId, value);
|
||||
onLinked(inst.instance_id);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not link that instance. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="grid gap-4" noValidate>
|
||||
<Field
|
||||
label="Instance ID"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
error={error}
|
||||
hint={
|
||||
<>
|
||||
Find this on your install’s <code>Settings → Licence</code> page, or on
|
||||
the setup screen just after you first sign in. It looks like{" "}
|
||||
<code>6a0fe3f0-49d2-4aa1-967c-a3094b200b5d</code>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button type="submit" disabled={busy} className="justify-self-start">
|
||||
{busy ? "Linking…" : "Link and issue licence"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { LinkForm } from "./LinkForm";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function LinkPage() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
// This page only claims a PAID placeholder's real install UUID. Self-hosted
|
||||
// Free is created on the purchase page, so with no placeholder to claim there
|
||||
// is nothing to do here send them there.
|
||||
const claimId = useSearchParams().get("claim") ?? undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!claimId) router.replace("/purchase");
|
||||
}, [claimId, router]);
|
||||
|
||||
if (!claimId) return null;
|
||||
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Link an install"
|
||||
subtitle="Every licence is tied to one install, so we need its ID before we can issue yours. Paste it below and your licence is ready on the next screen."
|
||||
/>
|
||||
<LinkForm
|
||||
claimId={claimId}
|
||||
onLinked={(instanceId) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
// Straight to the download, not back to a list: the licence is
|
||||
// the thing they came for.
|
||||
router.push(`/instances/${instanceId}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { API_BASE, NotConnected, api, type License } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { InstanceRecord } from "@/components/InstanceRecord";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
@@ -34,21 +35,55 @@ export default function OverviewPage() {
|
||||
|
||||
const live = data.instances.filter((i) => i.status !== "deleted");
|
||||
|
||||
// Work the customer has to do, gathered across every instance. This is the
|
||||
// only account-level view of it each record only knows about itself.
|
||||
/*
|
||||
* Work the customer has to do, gathered across every instance. This is the
|
||||
* only account-level view of it — each record only knows about itself.
|
||||
*
|
||||
* Each item carries the way out of it. It used to be a list of sentences in
|
||||
* the rail, which told someone their licence was expiring and then made
|
||||
* them go and find the instance that owned it; the fix for every one of
|
||||
* these is one click, so the click belongs on the row.
|
||||
*/
|
||||
const attention = live.flatMap((i) => {
|
||||
const lic = byInstance.get(i.instance_id);
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
if (state === "none") return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
|
||||
if (state === "expired") return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
|
||||
if (state === "warn")
|
||||
const name = i.name || "An instance";
|
||||
|
||||
if (state === "none")
|
||||
return [
|
||||
{
|
||||
id: i.instance_id,
|
||||
text: `${i.name} expires`,
|
||||
note: `${daysRemaining(lic!.expires_at)}d`,
|
||||
text: `${name} has no licence yet`,
|
||||
note: "Pick a plan and we will issue a licence for this install.",
|
||||
href: "/purchase",
|
||||
action: "Get a licence",
|
||||
tag: "",
|
||||
},
|
||||
];
|
||||
if (state === "expired")
|
||||
return [
|
||||
{
|
||||
id: i.instance_id,
|
||||
text: `${name} has expired`,
|
||||
note: "Servers keep running and agents keep their keys, but changes are disabled until you renew.",
|
||||
href: `/instances/${i.instance_id}`,
|
||||
action: "Renew",
|
||||
tag: "now",
|
||||
},
|
||||
];
|
||||
if (state === "warn") {
|
||||
const d = daysRemaining(lic!.expires_at);
|
||||
return [
|
||||
{
|
||||
id: i.instance_id,
|
||||
text: `${name} expires in ${d} ${d === 1 ? "day" : "days"}`,
|
||||
note: "Renewing extends the term from the current expiry, so nothing is lost by renewing early.",
|
||||
href: `/instances/${i.instance_id}`,
|
||||
action: "Renew",
|
||||
tag: `${d}d`,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -71,32 +106,43 @@ export default function OverviewPage() {
|
||||
/>
|
||||
|
||||
{live.length === 0 ? (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">No instances yet</h2>
|
||||
<p className="text-ink-2">
|
||||
Create a free cloud instance and we host it, with your licence applied automatically. Or run Vantage on your own server and get its licence free or paid from the purchase page.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
/*
|
||||
* An empty screen is an invitation to act, and the two ways in
|
||||
* are genuinely different products — we host it, or you do. One
|
||||
* button and a paragraph explaining the other option made the
|
||||
* self-hosted path read as an afterthought, which it is not.
|
||||
*/
|
||||
<div className="grid gap-4 rounded border border-rule bg-panel p-6">
|
||||
<div className="grid gap-2">
|
||||
<h2 className="text-xl">No instances yet</h2>
|
||||
<p className="max-w-[52ch] text-ink-2">An instance is one Vantage control plane. Start a hosted one in about a minute, or license an install you run yourself.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid content-start gap-2 rounded border border-rule p-4">
|
||||
<h3 className="text-[1.05rem]">Cloud</h3>
|
||||
<p className="text-[0.82rem] text-ink-2">We host it, on a subdomain of vantage.hostxtra.co.uk, with the licence applied for you.</p>
|
||||
<div className="pt-1">
|
||||
<LinkButton href="/purchase">Create a cloud instance</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid content-start gap-2 rounded border border-rule p-4">
|
||||
<h3 className="text-[1.05rem]">Self-hosted</h3>
|
||||
<p className="text-[0.82rem] text-ink-2">You host it. Get the licence here, then paste your install’s ID to bind it.</p>
|
||||
<div className="pt-1">
|
||||
<LinkButton variant="line" href="/purchase">
|
||||
License my own install
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[0.78rem] text-ink-3">The Free tier covers 5 servers and needs no card.</p>
|
||||
</div>
|
||||
) : (
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
{attention.length > 0 && (
|
||||
<RailCard title="Needs you" count={attention.length}>
|
||||
<ul className="grid gap-2">
|
||||
{attention.map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
|
||||
<span>{a.text}</span>
|
||||
{a.note && <span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">{a.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
<RailCard title="Your team" count={people.data?.length}>
|
||||
<ul className="grid gap-2">
|
||||
{(people.data ?? []).slice(0, 5).map((p) => (
|
||||
@@ -147,6 +193,32 @@ export default function OverviewPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/*
|
||||
* First in the main column, not in the rail. This is the
|
||||
* reason the page is open; the rail is for things that are
|
||||
* merely true. It disappears entirely when there is nothing
|
||||
* in it rather than saying "all clear", which is a line
|
||||
* nobody needs to read twice a week.
|
||||
*/}
|
||||
{attention.length > 0 && (
|
||||
<Panel title="Needs you" meta={`${attention.length} ${attention.length === 1 ? "item" : "items"}`} bodyless>
|
||||
<ul className="grid">
|
||||
{attention.map((a) => (
|
||||
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3 last:border-b-0">
|
||||
<div className="grid min-w-0 gap-0.5">
|
||||
<span className="flex items-center gap-2 text-[0.9rem] font-semibold">
|
||||
{a.text}
|
||||
{a.tag && <span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-warn">{a.tag}</span>}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-ink-3">{a.note}</span>
|
||||
</div>
|
||||
<LinkButton href={a.href}>{a.action}</LinkButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{live.map((i, n) => {
|
||||
const lic = byInstance.get(i.instance_id);
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
|
||||
@@ -6,25 +6,16 @@ import Link from "next/link";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
|
||||
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
|
||||
import { featureDesc, featureLabel } from "@/lib/features";
|
||||
|
||||
/* Tiers in the order a customer reads them, cheapest first. */
|
||||
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/* Human labels for feature keys. The catalogue names them by key; this is the
|
||||
* one place the customer-facing wording lives. */
|
||||
const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
};
|
||||
const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
};
|
||||
function featureLabel(key: string) {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
/* Feature wording lives in lib/features.ts, shared with the staff
|
||||
* configurator. It was duplicated here and there, and the two copies had
|
||||
* already drifted. */
|
||||
|
||||
interface Choice {
|
||||
tier: Tier;
|
||||
@@ -151,10 +142,13 @@ export function PurchaseForm() {
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the licence."),
|
||||
});
|
||||
|
||||
// Self-hosted checkout names the install's REAL UUID, so the instance is
|
||||
// linked (or an already-owned one reused) before Paddle opens. The webhook
|
||||
// then issues straight onto it — there is no placeholder to claim afterwards.
|
||||
const startCheckout = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = name.trim();
|
||||
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHosted(trimmed);
|
||||
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHostedCheckout(uuid.trim(), trimmed);
|
||||
return r.instance_id;
|
||||
},
|
||||
onSuccess: async (instanceId) => {
|
||||
@@ -168,12 +162,6 @@ export function PurchaseForm() {
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start checkout."),
|
||||
});
|
||||
|
||||
const claim = useMutation({
|
||||
mutationFn: () => api.claimLink(pending!.instanceId, uuid.trim()),
|
||||
onSuccess: () => router.push("/"),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link the install."),
|
||||
});
|
||||
|
||||
if (optionsQ.isLoading || account.isLoading) {
|
||||
return <p className="text-ink-3">Loading plans…</p>;
|
||||
}
|
||||
@@ -294,7 +282,7 @@ export function PurchaseForm() {
|
||||
{featureKeys.map((key) => {
|
||||
const st = featureStateFor(plan, rows, options.env, choice.term, key);
|
||||
return (
|
||||
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
|
||||
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
|
||||
{st === "included" ? (
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
|
||||
) : st === "absent" ? (
|
||||
@@ -317,11 +305,13 @@ export function PurchaseForm() {
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{selfHostedFree && (
|
||||
<Block n={3} label="Your install">
|
||||
{dep === "self_hosted" && (
|
||||
<Block n={paid ? 4 : 3} label="Your install">
|
||||
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence nothing to pay.
|
||||
{paid
|
||||
? "Every licence binds to one install, so stand your control plane up first and paste the instance ID it reports. We attach it to your account now and the licence lands the moment payment clears. Already have an instance here? Paste its ID to upgrade it."
|
||||
: "Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence — nothing to pay."}
|
||||
</p>
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
|
||||
@@ -389,7 +379,7 @@ export function PurchaseForm() {
|
||||
) : (
|
||||
<Cta
|
||||
label={startCheckout.isPending ? "Starting…" : "Continue to payment"}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending || (dep === "self_hosted" && !UUID_RE.test(uuid.trim()))}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
startCheckout.mutate();
|
||||
@@ -398,28 +388,13 @@ export function PurchaseForm() {
|
||||
))}
|
||||
|
||||
{/* Phase B: after the checkout has been opened. */}
|
||||
{pending?.deployment === "self_hosted" && (
|
||||
{pending && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Once payment clears, paste the instance ID your install reports (Settings → Licence) to receive your licence.</p>
|
||||
<input
|
||||
value={uuid}
|
||||
onChange={(e) => setUuid(e.target.value)}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
|
||||
/>
|
||||
<Cta
|
||||
label={claim.isPending ? "Linking…" : "Link and issue licence"}
|
||||
disabled={!uuid.trim() || claim.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
claim.mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pending?.deployment === "cloud" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears no further steps.</p>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
{pending.deployment === "cloud"
|
||||
? "Your instance is being set up. Its licence appears the moment payment clears — no further steps."
|
||||
: "Your install is attached to this account. Its licence appears the moment payment clears — no further steps."}
|
||||
</p>
|
||||
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
|
||||
Go to your instance
|
||||
</Link>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Button, controlClass } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
import { PageFrame, RailCard } from "@/components/PageFrame";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -24,6 +24,7 @@ export function InvitePanel() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<AccountRole>("member");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
|
||||
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
|
||||
@@ -46,8 +47,14 @@ export function InvitePanel() {
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.removeAccountUser(id),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
onSuccess: () => {
|
||||
setConfirming(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (e) => {
|
||||
setConfirming(null);
|
||||
fail(e);
|
||||
},
|
||||
});
|
||||
|
||||
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
@@ -82,11 +89,7 @@ export function InvitePanel() {
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Account role
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as AccountRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as AccountRole)} className={controlClass()}>
|
||||
{assignable.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
@@ -175,22 +178,46 @@ export function InvitePanel() {
|
||||
: "Invitation pending"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{canManage && !isSelf && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Remove ${u.email}? They lose access to every instance on this account.`,
|
||||
)
|
||||
)
|
||||
remove.mutate(u.user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{canManage &&
|
||||
!isSelf &&
|
||||
/*
|
||||
* Inline rather than window.confirm(): removing
|
||||
* someone here revokes them from every instance
|
||||
* on the account, which is more than the word
|
||||
* "Remove" beside one row implies, and the
|
||||
* browser dialog cannot show the consequence
|
||||
* where the eye already is.
|
||||
*/
|
||||
(confirming === u.user_id ? (
|
||||
<span className="inline-flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-[0.82rem] text-ink-2">
|
||||
Removes access to every instance.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline disabled:opacity-50"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(u.user_id)}
|
||||
>
|
||||
{remove.isPending ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] text-ink-2 underline"
|
||||
onClick={() => setConfirming(null)}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => setConfirming(u.user_id)}
|
||||
>
|
||||
Remove<span className="sr-only"> {u.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Field } from "@/components/Field";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { controlClass } from "@/components/Button";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export function AccountSearch() {
|
||||
const [q, setQ] = useState("");
|
||||
@@ -14,56 +16,65 @@ export function AccountSearch() {
|
||||
queryFn: () => api.staff.accounts(q || undefined),
|
||||
});
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Field
|
||||
label="Search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
hint="Name, email, Paddle customer ID, or an instance UUID."
|
||||
/>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Account</th>
|
||||
<th className="px-4 py-2.5">Billing email</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).map((a) => (
|
||||
<tr
|
||||
key={a.account_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/accounts/${a.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Panel>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Search</span>
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Name, email, ctm_… or an instance UUID"
|
||||
className={controlClass()}
|
||||
/>
|
||||
</label>
|
||||
</Panel>
|
||||
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Account</TH>
|
||||
<TH>Billing email</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Created</TH>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((a) => (
|
||||
<TR key={a.account_id}>
|
||||
<TD>
|
||||
<Link href={`/staff/accounts/${a.account_id}`} className="font-semibold text-accent no-underline hover:underline">
|
||||
{a.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[0.82rem]">
|
||||
{a.billing_email}
|
||||
</td>
|
||||
<td className="px-4 py-3">{a.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(a.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
<Sub>
|
||||
<span className="font-mono">{a.account_id}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD className="font-mono text-[0.82rem] text-ink-2">{a.billing_email}</TD>
|
||||
<TD className="text-ink-2">{a.status}</TD>
|
||||
<TD className="font-mono tabular-nums text-ink-2">{formatDate(a.created_at)}</TD>
|
||||
<TD numeric>
|
||||
<Link href={`/staff/accounts/${a.account_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
|
||||
Open
|
||||
</Link>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!isFetching && (data ?? []).length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">
|
||||
No account matches that. Try the instance UUID from the customer’s
|
||||
email.
|
||||
</p>
|
||||
</TBody>
|
||||
</Table>
|
||||
|
||||
{!isFetching && rows.length === 0 && (
|
||||
<EmptyState
|
||||
title={q ? "No account matches that." : "No accounts yet."}
|
||||
body={q ? "Try the instance UUID from the customer's email — it resolves to the account that owns it." : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export default function AccountDetailPage() {
|
||||
const id = String(useParams().id);
|
||||
@@ -17,7 +19,7 @@ export default function AccountDetailPage() {
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<div className="grid gap-5">
|
||||
<PageHeader
|
||||
back={{ href: "/staff/accounts", label: "Accounts" }}
|
||||
title={data.account.name}
|
||||
@@ -29,72 +31,126 @@ export default function AccountDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Panel title="Instances">
|
||||
<ul className="grid gap-2">
|
||||
{data.instances.map((i) => (
|
||||
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="text-accent underline">
|
||||
{i.name || i.instance_id}
|
||||
</Link>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{i.deployment} · {i.tier ?? "no tier"} · {i.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.instances.length === 0 && <li className="text-ink-3">None.</li>}
|
||||
</ul>
|
||||
{/*
|
||||
* Four lists of "thing · thing · thing" became four tables. Each row
|
||||
* held three or four separate facts run into one string with
|
||||
* middots, which cannot be scanned down a column — and a staff
|
||||
* screen is read by scanning down a column.
|
||||
*/}
|
||||
<Panel title="Instances" meta={String(data.instances.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Instance</TH>
|
||||
<TH>Deployment</TH>
|
||||
<TH>Tier</TH>
|
||||
<TH>Status</TH>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.instances.map((i) => (
|
||||
<TR key={i.instance_id}>
|
||||
<TD>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="font-semibold text-accent no-underline hover:underline">
|
||||
{i.name || "Unnamed instance"}
|
||||
</Link>
|
||||
<Sub>
|
||||
<span className="font-mono">{i.instance_id.slice(0, 8)}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD className="text-ink-2">{i.deployment === "cloud" ? "Cloud" : "Self-hosted"}</TD>
|
||||
<TD className="text-ink-2">{i.tier?.replace("_", " ") ?? "—"}</TD>
|
||||
<TD className="text-ink-2">{i.status}</TD>
|
||||
<TD numeric>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
|
||||
Open
|
||||
</Link>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.instances.length === 0 && <EmptyState title="No instances on this account." body="They have signed up but not created or linked anything yet." />}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Subscriptions">
|
||||
<ul className="grid gap-2">
|
||||
{data.subscriptions.map((s) => (
|
||||
<li key={s.subscription_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span>
|
||||
{s.tier.replace("_", " ")} · {s.term}
|
||||
</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{s.status} · renews {formatDate(s.current_period_end)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.subscriptions.length === 0 && <li className="text-ink-3">None.</li>}
|
||||
</ul>
|
||||
<Panel title="Subscriptions" meta={String(data.subscriptions.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Tier</TH>
|
||||
<TH>Billing</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Renews</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.subscriptions.map((s) => (
|
||||
<TR key={s.subscription_id}>
|
||||
<TD>{s.tier.replace("_", " ")}</TD>
|
||||
<TD className="text-ink-2">{s.term}</TD>
|
||||
<TD className="text-ink-2">{s.status}</TD>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(s.current_period_end)}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.subscriptions.length === 0 && <EmptyState title="No subscriptions." body="Everything on this account is Free, or nothing has been bought yet." />}
|
||||
</Panel>
|
||||
|
||||
<Panel title="People">
|
||||
<ul className="grid gap-2">
|
||||
{data.users.map((u) => (
|
||||
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="font-mono text-[0.82rem]">{u.email}</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">{u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.users.length === 0 && <li className="text-ink-3">None this is a cloud account, so its people sign in with their control-plane details.</li>}
|
||||
</ul>
|
||||
<Panel title="People" meta={String(data.users.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Email</TH>
|
||||
<TH>Role</TH>
|
||||
<TH>Verified</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.users.map((u) => (
|
||||
<TR key={u.user_id}>
|
||||
<TD className="font-mono text-[0.82rem]">{u.email}</TD>
|
||||
<TD className="text-ink-2">{u.account_role}</TD>
|
||||
<TD className="text-ink-2">
|
||||
{u.verified_at ? (
|
||||
<span className="font-mono tabular-nums">{formatDate(u.verified_at)}</span>
|
||||
) : (
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-warn">Not verified</span>
|
||||
)}
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.users.length === 0 && (
|
||||
<EmptyState title="No HQ people on this account." body="This is a cloud account, so its people sign in with their control-plane details instead." />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Audit">
|
||||
<ul className="grid gap-1 font-mono text-[0.82rem]">
|
||||
{data.audit.map((e, n) => (
|
||||
<li key={n} className="flex flex-wrap justify-between gap-2 text-ink-2">
|
||||
<span>
|
||||
{e.action} · {e.actor}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">{formatDate(e.created_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
|
||||
</ul>
|
||||
<Panel title="Audit" meta="Newest first" bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Date</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.audit.map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(e.created_at)}</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.audit.length === 0 && <EmptyState title="Nothing recorded against this account yet." />}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,16 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate, formatStamp } from "@/lib/format";
|
||||
import { Field } from "@/components/Field";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { controlClass } from "@/components/Button";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export default function AuditPage() {
|
||||
const [filter, setFilter] = useState("");
|
||||
const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
|
||||
|
||||
const rows = (data ?? []).filter((e) =>
|
||||
filter
|
||||
? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
const rows = (data ?? []).filter((e) => (filter ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase()) : true));
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
@@ -24,30 +22,59 @@ export default function AuditPage() {
|
||||
subtitle="Every mutating action across every account, newest first."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<Field
|
||||
label="Filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
hint="Action, actor or target."
|
||||
/>
|
||||
<ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
|
||||
{rows.map((e, n) => (
|
||||
<li
|
||||
key={n}
|
||||
className="grid gap-1 border-b border-rule-soft pb-2 last:border-0 sm:grid-cols-[11rem_1fr]"
|
||||
>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)} {formatStamp(e.created_at)}
|
||||
</span>
|
||||
<span className="text-ink-2">
|
||||
<b className="text-ink">{e.action}</b> · {e.actor}
|
||||
{e.target && ` · ${e.target}`}
|
||||
{e.detail && ` · ${e.detail}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing matches that.</li>}
|
||||
</ul>
|
||||
|
||||
<Panel>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Filter</span>
|
||||
<input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Action, actor or target"
|
||||
className={controlClass()}
|
||||
/>
|
||||
</label>
|
||||
</Panel>
|
||||
|
||||
{/*
|
||||
* A table, not a list of mono sentences joined by middots. Every row
|
||||
* held five separate facts run together into one string, so nothing
|
||||
* could be scanned down a column — which is the only way anyone
|
||||
* reads an audit log looking for "who did this".
|
||||
*/}
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Time</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
<TH>Detail</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
{formatStamp(e.created_at)}
|
||||
<Sub>{formatDate(e.created_at)}</Sub>
|
||||
</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
<TD className="text-[0.82rem] text-ink-3">{e.detail ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{rows.length === 0 && (
|
||||
<EmptyState
|
||||
title={filter ? "Nothing matches that." : "No actions recorded yet."}
|
||||
body={filter ? "Clear the filter to see the whole log." : "Every licence issued, relinked or reaped is written here as it happens."}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { PageFrame } from "@/components/PageFrame";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { api, type CatalogueRow, type Term } from "@/lib/api";
|
||||
|
||||
const ENVS = ["sandbox", "production"] as const;
|
||||
@@ -69,35 +71,27 @@ export default function CataloguePage() {
|
||||
{isLoading ? (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading…</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4">
|
||||
{groups.map((g) => {
|
||||
const [deployment, tier] = g.split("/");
|
||||
const terms = termsFor(deployment);
|
||||
return (
|
||||
<section key={g} className="space-y-2">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "}
|
||||
{tier}
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-left text-ink-3">
|
||||
<th className="py-2 pr-3 font-normal">Component</th>
|
||||
<Panel key={g} title={`${deployment === "cloud" ? "Cloud" : "Self-Hosted"} ${tier}`} meta={terms.join(" · ")} bodyless>
|
||||
<Table className="min-w-[42rem]">
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Component</TH>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<th
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3 font-normal"
|
||||
>
|
||||
<TH key={`${env}-${t}`}>
|
||||
{env} / {t}
|
||||
</th>
|
||||
</TH>
|
||||
)),
|
||||
)}
|
||||
<th className="py-2 font-normal" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows
|
||||
.filter(
|
||||
(r) =>
|
||||
@@ -111,19 +105,11 @@ export default function CataloguePage() {
|
||||
JSON.stringify(ids) !==
|
||||
JSON.stringify(r.price_ids ?? {});
|
||||
return (
|
||||
<tr
|
||||
key={k}
|
||||
className="border-b border-rule/60"
|
||||
>
|
||||
<td className="py-2 pr-3 text-ink">
|
||||
{componentLabel(r)}
|
||||
</td>
|
||||
<TR key={k}>
|
||||
<TD className="text-ink">{componentLabel(r)}</TD>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<td
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3"
|
||||
>
|
||||
<TD key={`${env}-${t}`}>
|
||||
<input
|
||||
value={
|
||||
ids[env]?.[t] ?? ""
|
||||
@@ -145,12 +131,12 @@ export default function CataloguePage() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink"
|
||||
className="w-40 rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.78rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
</TD>
|
||||
)),
|
||||
)}
|
||||
<td className="py-2">
|
||||
<TD numeric>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
@@ -162,18 +148,17 @@ export default function CataloguePage() {
|
||||
price_ids: ids,
|
||||
})
|
||||
}
|
||||
className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
|
||||
className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</TBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,12 @@ import clsx from "clsx";
|
||||
import { api, type Deployment, type InjectionState } from "@/lib/api";
|
||||
import { Ledger } from "@/components/Ledger";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TermBar } from "@/components/TermBar";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { licenceState } from "@/lib/format";
|
||||
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
|
||||
import { IssuePanel } from "./IssuePanel";
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
current: { label: "Control plane holds the current licence", tone: "text-valid" },
|
||||
@@ -23,6 +27,7 @@ const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
|
||||
export default function StaffInstancePage() {
|
||||
const id = String(useParams().id);
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["staff-instance", id],
|
||||
queryFn: () => api.staff.instance(id),
|
||||
@@ -32,6 +37,13 @@ export default function StaffInstancePage() {
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
|
||||
const current = data.licenses.find((l) => !l.superseded_by);
|
||||
// A cloud placeholder has no control-plane row yet, so there is no host to
|
||||
// move and nothing to rename — the panel's wording and its control are both
|
||||
// read from this one answer rather than from the deployment alone, which is
|
||||
// how they came to contradict each other.
|
||||
const movesHost = data.instance.deployment === "cloud" && !data.instance.placeholder;
|
||||
const cloudPlaceholder = data.instance.deployment === "cloud" && data.instance.placeholder;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
@@ -59,11 +71,53 @@ export default function StaffInstancePage() {
|
||||
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">Licence history</h2>
|
||||
{/*
|
||||
* The live licence is the one nothing has superseded, which is the
|
||||
* record's own statement of the fact — not its position in the
|
||||
* array, which is the server's ordering and not a guarantee.
|
||||
*/}
|
||||
{current && (
|
||||
<Panel title="Current licence" meta={current.license_id}>
|
||||
<TermBar issuedAt={current.issued_at} expiresAt={current.expires_at} state={licenceState(current.expires_at, true)} className="max-w-xl" />
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Licence history" meta="Append-only">
|
||||
<Ledger licenses={data.licenses} />
|
||||
<IssuePanel instanceId={data.instance.instance_id} />
|
||||
</section>
|
||||
</Panel>
|
||||
|
||||
{/*
|
||||
* Staff rename has no cooldown and does not start the customer's:
|
||||
* fixing a name on someone's behalf must not spend their next 24
|
||||
* hours.
|
||||
*/}
|
||||
<Panel title="Name" meta={movesHost ? "Moves the address" : "Label only"}>
|
||||
{cloudPlaceholder ? (
|
||||
// The API refuses this with a 409, so offering the control
|
||||
// would only be a form that cannot succeed.
|
||||
<p className="text-[0.85rem] text-ink-3">
|
||||
This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that.
|
||||
</p>
|
||||
) : (
|
||||
/*
|
||||
* Keyed on the instance so a success note cannot follow staff
|
||||
* from one instance page to the next — the element stays
|
||||
* mounted across that navigation.
|
||||
*/
|
||||
<RenamePanel
|
||||
key={data.instance.instance_id}
|
||||
movesHost={movesHost}
|
||||
currentName={data.instance.name}
|
||||
currentSlug={data.instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.staff.renameInstance(data.instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,14 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { api, type Tier } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { formatDate, licenceState } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { controlClass } from "@/components/Button";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { TermSpark } from "@/components/TermBar";
|
||||
|
||||
const SELECT = controlClass("w-auto");
|
||||
|
||||
export default function LicensesPage() {
|
||||
const [tier, setTier] = useState<"" | Tier>("");
|
||||
@@ -14,9 +20,8 @@ export default function LicensesPage() {
|
||||
|
||||
// Filtered here rather than server-side: the endpoint caps at 500 rows and
|
||||
// staff are narrowing a list they can already see.
|
||||
const rows = (data ?? []).filter(
|
||||
(l) => (!tier || l.tier === tier) && (!reason || l.reason === reason),
|
||||
);
|
||||
const rows = (data ?? []).filter((l) => (!tier || l.tier === tier) && (!reason || l.reason === reason));
|
||||
const filtered = Boolean(tier || reason);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
@@ -25,78 +30,93 @@ export default function LicensesPage() {
|
||||
subtitle="Append-only. A renewal writes a new row and supersedes the old one."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier | "")}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by tier"
|
||||
>
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
<option value="self_hosted">Self-Hosted (legacy)</option>
|
||||
</select>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by reason"
|
||||
>
|
||||
<option value="">All reasons</option>
|
||||
<option value="new">New</option>
|
||||
<option value="renewal">Renewal</option>
|
||||
<option value="tier_change">Tier change</option>
|
||||
<option value="relink">Relink</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Issued</th>
|
||||
<th className="px-4 py-2.5">Instance</th>
|
||||
<th className="px-4 py-2.5">Tier</th>
|
||||
<th className="px-4 py-2.5">Reason</th>
|
||||
<th className="px-4 py-2.5">Expires</th>
|
||||
<th className="px-4 py-2.5">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((l) => (
|
||||
<tr
|
||||
key={l.license_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.issued_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/instances/${l.instance_id}`}
|
||||
className="font-mono text-[0.82rem] text-accent underline"
|
||||
>
|
||||
{l.instance_id.slice(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.expires_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-3">
|
||||
{l.superseded_by ? "superseded" : "current"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Panel>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Tier</span>
|
||||
<select value={tier} onChange={(e) => setTier(e.target.value as Tier | "")} className={SELECT} aria-label="Filter by tier">
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
<option value="self_hosted">Self-Hosted (legacy)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Reason</span>
|
||||
<select value={reason} onChange={(e) => setReason(e.target.value)} className={SELECT} aria-label="Filter by reason">
|
||||
<option value="">All reasons</option>
|
||||
<option value="new">New</option>
|
||||
<option value="renewal">Renewal</option>
|
||||
<option value="tier_change">Tier change</option>
|
||||
<option value="relink">Relink</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Issued</TH>
|
||||
<TH>Instance</TH>
|
||||
<TH>Tier</TH>
|
||||
<TH>Reason</TH>
|
||||
<TH>Term</TH>
|
||||
<TH>Expires</TH>
|
||||
<TH>State</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
// A superseded row is overprinted rather than hidden:
|
||||
// it is the only record of why an instance stopped
|
||||
// working on a given date.
|
||||
<TR key={l.license_id} className={dead ? "text-ink-3" : undefined}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.issued_at)}</TD>
|
||||
<TD>
|
||||
<Link href={`/staff/instances/${l.instance_id}`} className="font-mono text-[0.82rem] text-accent no-underline hover:underline">
|
||||
{l.instance_id.slice(0, 8)}
|
||||
</Link>
|
||||
<Sub>
|
||||
<span className="font-mono">{l.license_id.slice(0, 8)}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD>{l.tier.replace("_", " ")}</TD>
|
||||
<TD className="text-ink-2">{l.reason.replace("_", " ")}</TD>
|
||||
{/* A superseded row's term is not a countdown to
|
||||
anything — it ended when its successor was
|
||||
issued, so drawing a bar would invite a
|
||||
comparison that means nothing. */}
|
||||
<TD>
|
||||
{dead ? (
|
||||
<span className="font-mono text-[0.72rem] text-ink-3">—</span>
|
||||
) : (
|
||||
<TermSpark issuedAt={l.issued_at} expiresAt={l.expires_at} state={licenceState(l.expires_at, true)} />
|
||||
)}
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.expires_at)}</TD>
|
||||
<TD>
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3">{dead ? "superseded" : "current"}</span>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
|
||||
{rows.length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">No licences match those filters.</p>
|
||||
<EmptyState
|
||||
title={filtered ? "No licences match those filters." : "No licences issued yet."}
|
||||
body={filtered ? "Clear a filter to widen the search." : "Every issue, renewal and relink writes a row here."}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Queue } from "@/components/Queue";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { daysRemaining, formatStamp } from "@/lib/format";
|
||||
@@ -123,46 +125,37 @@ export default function StaffDashboard() {
|
||||
</RailCard>
|
||||
}
|
||||
>
|
||||
<section className="overflow-hidden rounded border border-rule bg-panel">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[0.95rem]">Recent activity</h2>
|
||||
<p className="text-[0.8rem] text-ink-2">Every licence issued, relinked or reaped, newest first.</p>
|
||||
</div>
|
||||
<Link href="/staff/audit" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Full audit
|
||||
<Panel
|
||||
title="Recent activity"
|
||||
actions={
|
||||
<Link href="/staff/audit" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
|
||||
Full audit →
|
||||
</Link>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-[0.86rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule-soft font-mono text-[0.64rem] uppercase tracking-[0.11em] text-ink-3">
|
||||
<th className="px-4 py-2 font-normal">Time</th>
|
||||
<th className="px-4 py-2 font-normal">Action</th>
|
||||
<th className="px-4 py-2 font-normal">Target</th>
|
||||
<th className="px-4 py-2 font-normal">Actor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<tr key={n} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</td>
|
||||
<td className="px-4 py-2.5">{e.action}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
|
||||
</tr>
|
||||
))}
|
||||
{audit.data?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-ink-3">
|
||||
Nothing yet today.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
bodyless
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Time</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{audit.data?.length === 0 && <EmptyState title="Nothing yet today." body="Every licence issued, relinked or reaped appears here as it happens." />}
|
||||
</Panel>
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Panel } from "@/components/Panel";
|
||||
|
||||
const SUPPORT_LEVELS = [
|
||||
{ value: "community", label: "Community" },
|
||||
@@ -31,7 +32,7 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
@@ -48,7 +49,7 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">−1 is unlimited</span>
|
||||
</label>
|
||||
@@ -58,7 +59,7 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl
|
||||
<select
|
||||
value={draft.support_level}
|
||||
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
|
||||
>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
@@ -76,7 +77,12 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl
|
||||
|
||||
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
|
||||
|
||||
<button type="button" disabled={!dirty || saving} onClick={() => onSave(draft)} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => onSave(draft)}
|
||||
className="justify-self-start rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink disabled:opacity-40"
|
||||
>
|
||||
{saving ? "Saving…" : "Save allowances"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -126,20 +132,19 @@ export default function PlansPage() {
|
||||
)}
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="space-y-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
<section key={deployment} className="grid gap-3">
|
||||
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<article key={`${p.deployment}/${p.tier}`} className="rounded-lg border border-rule bg-panel p-4">
|
||||
<header className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">{p.name}</h3>
|
||||
<span className="font-mono text-[0.75rem] text-ink-3">
|
||||
{p.deployment}/{p.tier}
|
||||
</span>
|
||||
</header>
|
||||
<Panel
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
title={p.name}
|
||||
meta={`${p.deployment}/${p.tier}`}
|
||||
actions={!p.active ? <span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-warn">Not offered</span> : undefined}
|
||||
>
|
||||
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
|
||||
</article>
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
import { AuthMessage, AuthShell } from "@/components/AuthShell";
|
||||
|
||||
function AcceptForm() {
|
||||
const token = useSearchParams().get("token") ?? "";
|
||||
@@ -17,60 +17,65 @@ function AcceptForm() {
|
||||
const accept = useMutation({
|
||||
mutationFn: () => api.acceptInvite(token, password),
|
||||
onSuccess: () => setDone(true),
|
||||
onError: (e) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
|
||||
});
|
||||
|
||||
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
|
||||
if (!token)
|
||||
return (
|
||||
<AuthMessage
|
||||
title="That link is incomplete"
|
||||
body="It is missing its token. Use the link in the invitation exactly as sent — some mail clients cut long links in half."
|
||||
action={{ href: "/login", label: "Go to sign in" }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (done)
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h1 className="text-3xl">You're in</h1>
|
||||
<p className="text-ink-2">Sign in with your email address and new password.</p>
|
||||
<Link href="/login" className="font-semibold text-accent underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
<AuthMessage
|
||||
title="You're in"
|
||||
body="Sign in with your email address and the password you just set."
|
||||
action={{ href: "/login", label: "Sign in" }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid max-w-md gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
accept.mutate();
|
||||
}}
|
||||
<AuthShell
|
||||
title="Choose a password"
|
||||
lede="You have been invited to a Vantage HQ account."
|
||||
footnote="Nobody who invited you can see this password, and it is never sent to them."
|
||||
>
|
||||
<h1 className="text-3xl">Choose a password</h1>
|
||||
<p className="text-ink-2">
|
||||
This password signs you into Vantage HQ and into every instance you are given
|
||||
access to. Nobody who invited you can see it.
|
||||
</p>
|
||||
<Field
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Button type="submit" disabled={accept.isPending || password.length < 12}>
|
||||
{accept.isPending ? "Setting…" : "Set password"}
|
||||
</Button>
|
||||
</form>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
accept.mutate();
|
||||
}}
|
||||
>
|
||||
<p className="text-[0.86rem] text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to.</p>
|
||||
<Field
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Button type="submit" disabled={accept.isPending || password.length < 12} className="w-full justify-center">
|
||||
{accept.isPending ? "Setting…" : "Set password and continue"}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-16">
|
||||
<Suspense fallback={<p className="text-ink-3">Loading…</p>}>
|
||||
<AcceptForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Suspense fallback={<AuthShell title="Choose a password" lede="One moment." />}>
|
||||
<AcceptForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
import { AuthShell } from "@/components/AuthShell";
|
||||
|
||||
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
@@ -38,82 +39,56 @@ export default function LoginPage() {
|
||||
|
||||
if (offline)
|
||||
return (
|
||||
<Main>
|
||||
<AuthShell title="Sign in">
|
||||
<NotConnectedPanel url={API_BASE} />
|
||||
</Main>
|
||||
</AuthShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<Main>
|
||||
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
|
||||
<div className="mb-7 flex flex-col items-center gap-2 text-center">
|
||||
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
</span>
|
||||
<h1 className="text-[1.16rem]">Sign in</h1>
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Licences · instances · billing
|
||||
</p>
|
||||
</div>
|
||||
<AuthShell
|
||||
title="Sign in"
|
||||
lede="Licences, instances and billing for your account."
|
||||
/*
|
||||
* HQ and the Vantage console are separate sign-ins on separate
|
||||
* hosts, and the two get confused — someone lands here with their
|
||||
* console password and reads the generic failure as a broken
|
||||
* account. Saying which door this is costs one line.
|
||||
*/
|
||||
footnote="This is the portal for your licence and billing. Your servers are managed inside your Vantage instance, which signs in separately."
|
||||
>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field label="Email" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input type="checkbox" checked={staff} onChange={(e) => setStaff(e.target.checked)} className="accent-[var(--accent)]" />
|
||||
I work at Vantage
|
||||
</label>
|
||||
<Button type="submit" disabled={busy} className="w-full justify-center">
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
className="accent-[var(--accent)]"
|
||||
/>
|
||||
I work at Vantage
|
||||
</label>
|
||||
<Button type="submit" disabled={busy} className="w-full justify-center">
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
{SITE_URL && (
|
||||
<>
|
||||
<div className="h-px bg-rule-soft" />
|
||||
|
||||
{SITE_URL && (
|
||||
<>
|
||||
<div className="my-5 h-px bg-rule-soft" />
|
||||
|
||||
{/* Signup lives on the marketing site's /start, not here. */}
|
||||
<p className="text-center text-[0.82rem] text-ink-3">
|
||||
No account?{" "}
|
||||
<a href={`${SITE_URL}/start`} className="text-accent underline">
|
||||
Create one
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function Main({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
|
||||
{children}
|
||||
</main>
|
||||
{/* Signup lives on the marketing site's /start, not here. */}
|
||||
<p className="text-center text-[0.82rem] text-ink-3">
|
||||
No account?{" "}
|
||||
<a href={`${SITE_URL}/start`} className="text-accent underline">
|
||||
Create one
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { AuthMessage, AuthShell } from "@/components/AuthShell";
|
||||
|
||||
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
function Verify() {
|
||||
const router = useRouter();
|
||||
@@ -25,50 +27,59 @@ function Verify() {
|
||||
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
}, [needsPassword, token, router]);
|
||||
if (needsPassword) return <Message title="One moment…" body="Taking you to set a password." />;
|
||||
if (needsPassword) return <AuthShell title="One moment…" lede="Taking you to set a password." />;
|
||||
|
||||
if (!token)
|
||||
return (
|
||||
<Message
|
||||
<AuthMessage
|
||||
title="That link is incomplete"
|
||||
body="It is missing its token. Use the link in the email exactly as sent."
|
||||
body="It is missing its token. Use the link in the email exactly as sent — some mail clients cut long links in half."
|
||||
action={{ href: "/login", label: "Go to sign in" }}
|
||||
/>
|
||||
);
|
||||
if (isLoading) return <Message title="Verifying…" body="One moment." />;
|
||||
|
||||
if (isLoading) return <AuthShell title="Verifying…" lede="One moment." />;
|
||||
|
||||
if (error || !data?.verified)
|
||||
return (
|
||||
<Message
|
||||
<AuthMessage
|
||||
title="That link is invalid or has expired"
|
||||
body="Links last 24 hours and can only be used once. Sign up again to get a fresh one."
|
||||
body="Links last 24 hours and can only be used once. Signing in will send you a fresh one."
|
||||
action={{ href: "/login", label: "Go to sign in" }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">Email verified</h1>
|
||||
<p className="text-ink-2">Your account is ready.</p>
|
||||
<Link href="/login" className="justify-self-start text-accent underline">
|
||||
<AuthShell
|
||||
title="Email verified"
|
||||
lede="Your account is ready."
|
||||
footnote={
|
||||
SITE_URL ? (
|
||||
<>
|
||||
New to Vantage? The{" "}
|
||||
<a href={`${SITE_URL}/docs`} className="text-accent underline">
|
||||
getting started guide
|
||||
</a>{" "}
|
||||
walks through your first instance.
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<p className="text-[0.9rem] text-ink-2">Sign in to create your first instance. The Free tier covers 5 servers and needs no card.</p>
|
||||
<a
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">{title}</h1>
|
||||
<p className="text-ink-2">{body}</p>
|
||||
</div>
|
||||
</a>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
<Suspense fallback={null}>
|
||||
<Verify />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Suspense fallback={<AuthShell title="Verifying…" lede="One moment." />}>
|
||||
<Verify />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/*
|
||||
* The frame for every screen you can reach without a session: sign in, email
|
||||
* verification, and accepting an invitation.
|
||||
*
|
||||
* These three had drifted into three different layouts. Sign in was a centred
|
||||
* 26rem card with the lockup above it; verify and accept-invite were bare
|
||||
* left-aligned text on the full 1200px rail, with no masthead, no panel and no
|
||||
* brand anywhere on the page. Those two are the first screens a new customer
|
||||
* ever sees — arriving from an email, on a domain they have not visited before
|
||||
* — and they were the two that did not say whose product this is.
|
||||
*
|
||||
* There is no AppBar here on purpose: it carries navigation and an account
|
||||
* menu, and none of it works without a session.
|
||||
*/
|
||||
export function AuthShell({
|
||||
title,
|
||||
lede,
|
||||
children,
|
||||
footnote,
|
||||
}: {
|
||||
title: string;
|
||||
lede?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/** Sits outside the panel: orientation, not part of the task. */
|
||||
footnote?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
|
||||
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
|
||||
<div className="mb-7 flex flex-col items-center gap-2 text-center">
|
||||
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
|
||||
</span>
|
||||
<h1 className="text-[1.16rem]">{title}</h1>
|
||||
{lede && <p className="text-[0.86rem] text-ink-2">{lede}</p>}
|
||||
</div>
|
||||
|
||||
{children && <div className="grid gap-4 rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">{children}</div>}
|
||||
|
||||
{footnote && <div className="mt-5 text-center text-[0.8rem] text-ink-3">{footnote}</div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* A terminal state — verified, expired, already used, invalid. Always says what
|
||||
* happened and what to do next: a dead end that only reports the failure leaves
|
||||
* someone holding an email they cannot act on.
|
||||
*/
|
||||
export function AuthMessage({ title, body, action }: { title: string; body: React.ReactNode; action?: { href: string; label: string } }) {
|
||||
return (
|
||||
<AuthShell title={title}>
|
||||
<p className="text-[0.9rem] text-ink-2">{body}</p>
|
||||
{action && (
|
||||
<Link
|
||||
href={action.href}
|
||||
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,34 @@ type Variant = "solid" | "line";
|
||||
* border on the secondary variant. site/ does not have an accent-outlined
|
||||
* button and this app should not invent one.
|
||||
*/
|
||||
/*
|
||||
* The height every form control resolves to, buttons included.
|
||||
*
|
||||
* Padding alone cannot align them: a select is mono at 0.84rem and a button is
|
||||
* sans at 0.94rem, so identical padding still leaves them ~7px apart and a
|
||||
* filter row looks assembled from two different kits. It is the height the
|
||||
* button's own padding already computed to, so buttons do not move — everything
|
||||
* else comes up to meet them.
|
||||
*/
|
||||
export const CONTROL_HEIGHT = "h-11";
|
||||
|
||||
/*
|
||||
* An input or select that sits on a form row with a button. Mono, because in
|
||||
* this product the values typed into these are addresses, UUIDs and price IDs.
|
||||
*/
|
||||
export function controlClass(className?: string) {
|
||||
return clsx(
|
||||
CONTROL_HEIGHT,
|
||||
"w-full rounded border border-rule bg-panel-2 px-2.5 font-mono text-[0.88rem] text-ink",
|
||||
"focus:border-accent focus:outline-none",
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
|
||||
return clsx(
|
||||
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
|
||||
"inline-flex items-center gap-2 rounded border px-4 text-[0.94rem] font-semibold",
|
||||
CONTROL_HEIGHT,
|
||||
"transition-[filter,border-color] duration-150 hover:brightness-110",
|
||||
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { controlClass } from "./Button";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
className,
|
||||
...input
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
@@ -10,18 +13,15 @@ export function Field({
|
||||
}) {
|
||||
return (
|
||||
<label className="grid max-w-md gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
{...input}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
/>
|
||||
{error ? (
|
||||
<span className="text-[0.82rem] text-expired">{error}</span>
|
||||
) : hint ? (
|
||||
<span className="text-[0.82rem] text-ink-3">{hint}</span>
|
||||
) : null}
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
|
||||
{/*
|
||||
* className is pulled out of the spread rather than left in it: it
|
||||
* used to be spread onto the input and then overwritten by the
|
||||
* hardcoded one below, so a caller passing className got nothing and
|
||||
* no warning.
|
||||
*/}
|
||||
<input {...input} className={controlClass(className)} aria-invalid={error ? true : undefined} />
|
||||
{error ? <span className="text-[0.82rem] text-expired">{error}</span> : hint ? <span className="text-[0.82rem] text-ink-3">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||
import { api, type Instance, type License } from "@/lib/api";
|
||||
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { StatePill } from "./StatePill";
|
||||
import { TermBar } from "./TermBar";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
|
||||
const STRIPE = {
|
||||
@@ -35,7 +36,6 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
|
||||
const state = licenceState(license?.expires_at, Boolean(license));
|
||||
const days = license ? daysRemaining(license.expires_at) : 0;
|
||||
const cloud = instance.deployment === "cloud";
|
||||
const termDays = instance.tier === "free" ? 30 : 365;
|
||||
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -102,22 +102,10 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{license && state !== "expired" && (
|
||||
<div className="grid max-w-md gap-1.5">
|
||||
<div className="flex justify-between font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
<span>{days} days remaining</span>
|
||||
<span>Renews {formatDate(license.expires_at)}</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-sm bg-rule-soft">
|
||||
<div
|
||||
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
|
||||
style={{
|
||||
width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* The term is drawn for an expired licence too. The old bar hid
|
||||
itself once it lapsed, which removed the measurement at exactly
|
||||
the moment it started mattering. */}
|
||||
{license && <TermBar issuedAt={license.issued_at} expiresAt={license.expires_at} state={state} className="max-w-md" />}
|
||||
|
||||
{state === "expired" && (
|
||||
<div className="grid gap-1">
|
||||
@@ -168,14 +156,10 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{state === "none" ? (
|
||||
// A paid placeholder (awaiting_link) claims its real install
|
||||
// UUID in place. Anything else without a licence gets one from
|
||||
// the purchase page (self-hosted Free is created there).
|
||||
instance.status === "awaiting_link" ? (
|
||||
<LinkButton href={`/instances/link?claim=${instance.instance_id}`}>Link an install</LinkButton>
|
||||
) : (
|
||||
<LinkButton href="/purchase">Get a licence</LinkButton>
|
||||
)
|
||||
// Every unlicensed instance is answered from the purchase
|
||||
// page — self-hosted Free and paid both start there, and
|
||||
// both name the install's own UUID.
|
||||
<LinkButton href="/purchase">Get a licence</LinkButton>
|
||||
) : cloud && instance.slug ? (
|
||||
<>
|
||||
<LinkButton external href={`https://${instance.slug}.vantage.hostxtra.co.uk`}>
|
||||
|
||||
@@ -1,56 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Panel } from "./Panel";
|
||||
|
||||
/*
|
||||
* A licence blob is signed public data, not a secret it is useless on any
|
||||
* A licence blob is signed public data, not a secret — it is useless on any
|
||||
* instance other than the one it names. So it is safe to show inline, and
|
||||
* showing it is what stops a blocked download from blocking a paying customer.
|
||||
* That is also why it is never collapsed behind a toggle: someone whose
|
||||
* clipboard and download are both blocked has to be able to select it by hand.
|
||||
*
|
||||
* It is evidence rather than content, so it is set in a well with a keyed strip
|
||||
* saying what it is and how much of it there is, and given a fixed height. It
|
||||
* used to run to 250px of base64 and was the largest thing on the page, which
|
||||
* is a strange amount of room to give a string nobody reads.
|
||||
*
|
||||
* The download lives in the page header beside Renew, not here — it was in both
|
||||
* places, which is one button too many for one file.
|
||||
*/
|
||||
export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) {
|
||||
export function LicenceDelivery({ blob }: { instanceId: string; blob: string; downloadUrl: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(blob);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
try {
|
||||
await navigator.clipboard.writeText(blob);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard is refused without a secure context or a gesture the
|
||||
// browser trusts. The blob is on screen and selectable either way,
|
||||
// so this needs no error state.
|
||||
}
|
||||
}
|
||||
|
||||
const steps = [
|
||||
<>
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your install.
|
||||
Open <Code>Settings → Licence</Code> on your install.
|
||||
</>,
|
||||
<>Paste the licence into the box and save.</>,
|
||||
<>
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight away no restart.
|
||||
The page reports <Code>Valid</Code> straight away — no restart.
|
||||
</>,
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<h2 className="text-xl">Your licence</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={`vantage-${instanceId}.lic`}
|
||||
className="inline-flex items-center gap-2 rounded border border-accent bg-accent px-4 py-2.5 text-[0.94rem] font-semibold text-accent-ink"
|
||||
>
|
||||
Download licence
|
||||
</a>
|
||||
<Button variant="line" type="button" onClick={copy}>
|
||||
{copied ? "Copied" : "Copy to clipboard"}
|
||||
</Button>
|
||||
<Panel title="Your licence" meta="Paste into your install">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Licence key</span>
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">{blob.length.toLocaleString()} characters</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Dashed, because this is data to be carried somewhere else
|
||||
rather than a surface to read. */}
|
||||
<pre className="max-h-32 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 pr-24 font-mono text-[0.7rem] leading-relaxed text-ink-2">
|
||||
{blob}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="absolute right-2 top-2 rounded border border-rule bg-panel px-2.5 py-1 font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-2 hover:border-accent hover:text-accent"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">{blob}</pre>
|
||||
|
||||
{/* Numbered because this is an actual sequence — each step is only
|
||||
possible once the one before it is done. */}
|
||||
<ol className="grid gap-2">
|
||||
{steps.map((body, i) => (
|
||||
<li key={i} className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2">
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">{i + 1}</span>
|
||||
<span>{body}</span>
|
||||
<li key={i} className="grid grid-cols-[1.5rem_1fr] items-start gap-3 text-[0.84rem] text-ink-2">
|
||||
<span className="grid h-[1.4rem] place-items-center rounded-sm border border-rule font-mono text-[0.68rem] text-accent">{i + 1}</span>
|
||||
<span className="leading-[1.4rem]">{body}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children }: { children: React.ReactNode }) {
|
||||
return <code className="rounded-sm bg-accent-wash px-1 font-mono text-[0.8rem] text-ink">{children}</code>;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,43 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ApiError, api, type InstanceRole } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Button, controlClass } from "@/components/Button";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
|
||||
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
|
||||
|
||||
/*
|
||||
* Absent entirely for self-hosted instances the backend refuses those, and a
|
||||
* What each rank actually lets someone do, in the instance rather than in the
|
||||
* portal. The select used to offer three words with no statement of what they
|
||||
* bought — which is a permissions control that declines to explain permissions.
|
||||
*/
|
||||
const ROLE_GRANTS: Record<InstanceRole, string> = {
|
||||
owner: "Everything, including billing and deleting the instance.",
|
||||
admin: "Manage servers, workflows, secrets and settings.",
|
||||
member: "Use the instance. Cannot change settings or members.",
|
||||
};
|
||||
|
||||
const SELECT_QUIET =
|
||||
"rounded border border-transparent bg-transparent px-2 py-1 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-2 hover:border-rule focus:border-accent focus:text-ink focus:outline-none";
|
||||
|
||||
/* Same height as the Grant access button beside it — see controlClass. */
|
||||
const SELECT = controlClass("bg-panel");
|
||||
|
||||
/*
|
||||
* The access roster for one instance.
|
||||
*
|
||||
* Absent entirely for self-hosted instances — the backend refuses those, and a
|
||||
* panel that renders controls the server will reject is a panel that lies.
|
||||
*
|
||||
* The row is a monogram and an address set in mono, because in this product an
|
||||
* identity IS an address, and every other identifier on the screen — the
|
||||
* instance UUID, the licence reference — is mono too. The role is a fact most
|
||||
* of the time and a control occasionally, so it is drawn as text and only grows
|
||||
* a border on hover or focus: the old row made the dropdown the loudest thing
|
||||
* in it, which is backwards for a list people mostly read.
|
||||
*
|
||||
* Granting sits in its own strip on --panel-2 rather than as a fourth row of
|
||||
* naked controls, so the roster reads as the record and the strip as the action.
|
||||
*/
|
||||
export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient();
|
||||
@@ -18,6 +48,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const [selected, setSelected] = useState("");
|
||||
const [role, setRole] = useState<InstanceRole>("member");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ["members", instanceId],
|
||||
@@ -44,109 +75,175 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
onSuccess: () => {
|
||||
setConfirming(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (e) => {
|
||||
setConfirming(null);
|
||||
fail(e);
|
||||
},
|
||||
});
|
||||
|
||||
const myRole = session?.account_role;
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
|
||||
const rows = members.data ?? [];
|
||||
const granted = new Set(rows.map((m) => m.customer_user_id));
|
||||
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-xl">Who can sign in</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
|
||||
<Panel title="Who can sign in" meta={rows.length ? `${rows.length} ${rows.length === 1 ? "person" : "people"}` : undefined} bodyless>
|
||||
<div className="grid gap-3 px-4 pb-4 pt-3.5">
|
||||
<p className="text-[0.84rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
|
||||
{error && (
|
||||
<p role="alert" className="rounded border border-rule border-l-[3px] border-l-expired bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Nobody else can sign in yet."
|
||||
body={canManage ? "Add someone from your account below and a user is created for them inside this instance." : "An owner or admin can grant access."}
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid border-t border-rule-soft">
|
||||
{/*
|
||||
* Two columns on a phone — monogram and address — with the
|
||||
* controls dropping to their own full-width row beneath;
|
||||
* three columns from sm up, controls right-aligned. As one
|
||||
* wrapping flex row the address competed with a select and
|
||||
* two buttons for 320px and lost, and the confirm step put
|
||||
* three more elements into the same row.
|
||||
*/}
|
||||
{rows.map((m) => (
|
||||
<li
|
||||
key={m.member_id}
|
||||
className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-rule-soft px-4 py-3 last:border-b-0 sm:grid-cols-[auto_1fr_auto]"
|
||||
>
|
||||
<span aria-hidden className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.62rem] font-bold text-accent-ink">
|
||||
{m.email.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 break-all font-mono text-[0.84rem] sm:truncate sm:break-normal">{m.email}</span>
|
||||
|
||||
<ul className="grid gap-2">
|
||||
{(members.data ?? []).map((m) => (
|
||||
<li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
|
||||
<span>{m.email}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{canManage ? (
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={(e) =>
|
||||
changeRole.mutate({
|
||||
uid: m.customer_user_id,
|
||||
role: e.target.value as InstanceRole,
|
||||
})
|
||||
}
|
||||
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-mono text-[0.82rem]">{m.role}</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
|
||||
</ul>
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-2 sm:col-span-1 sm:flex-nowrap sm:justify-end">
|
||||
{canManage ? (
|
||||
<label className="shrink-0">
|
||||
<span className="sr-only">Role for {m.email}</span>
|
||||
<select
|
||||
value={m.role}
|
||||
title={ROLE_GRANTS[m.role]}
|
||||
onChange={(e) => changeRole.mutate({ uid: m.customer_user_id, role: e.target.value as InstanceRole })}
|
||||
className={SELECT_QUIET}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className="shrink-0 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-3">{m.role}</span>
|
||||
)}
|
||||
|
||||
{canManage &&
|
||||
/*
|
||||
* Confirming inline rather than through
|
||||
* window.confirm(), and in the row itself rather
|
||||
* than a dialog: it can say what revoking does,
|
||||
* where the eye already is.
|
||||
*/
|
||||
(confirming === m.customer_user_id ? (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<span className="text-[0.8rem] text-ink-2">Revoke access?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-expired px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-expired hover:bg-expired hover:text-panel disabled:opacity-50"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => revoke.mutate(m.customer_user_id)}
|
||||
>
|
||||
{revoke.isPending ? "Revoking…" : "Revoke"}
|
||||
</button>
|
||||
<button type="button" className="font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:text-ink" onClick={() => setConfirming(null)}>
|
||||
Keep
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
/* Quiet until intent: a row that is mostly read
|
||||
should not carry a permanently red control. */
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded border border-transparent px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:border-expired hover:text-expired"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setConfirming(m.customer_user_id);
|
||||
}}
|
||||
>
|
||||
Revoke<span className="sr-only"> access for {m.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (selected) grant.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
<option value="">Choose a person…</option>
|
||||
{candidates.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
{p.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" disabled={!selected || grant.isPending}>
|
||||
{grant.isPending ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<div className="grid gap-3 border-t border-rule bg-panel-2 px-4 py-3.5">
|
||||
{/* Stacked and full width on a phone; one row from sm up.
|
||||
Three controls side by side left the person select about
|
||||
90px wide, which is not enough to read an address in. */}
|
||||
<form
|
||||
className="grid gap-3 sm:flex sm:flex-wrap sm:items-end"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (selected) grant.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="grid min-w-0 gap-1.5 sm:flex-1">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Grant access to</span>
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)} className={SELECT} disabled={candidates.length === 0}>
|
||||
<option value="">{candidates.length === 0 ? "Everyone already has access" : "Choose a person…"}</option>
|
||||
{candidates.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
{p.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">As</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className={SELECT}>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" disabled={!selected || grant.isPending} className="w-full justify-center sm:w-auto">
|
||||
{grant.isPending ? "Granting…" : "Grant access"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{canManage && pending > 0 && (
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
|
||||
</p>
|
||||
{/* The chosen rank explains itself, rather than leaving three
|
||||
words to be guessed at. */}
|
||||
<p className="text-[0.8rem] text-ink-3">
|
||||
<span className="font-mono uppercase tracking-[0.08em]">{role}</span> — {ROLE_GRANTS[role]}
|
||||
</p>
|
||||
|
||||
{pending > 0 && (
|
||||
<p className="text-[0.8rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet, and cannot be granted access until they do.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ function CopyButton({ value }: { value: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
// Never the thing that wraps: it is 5 characters and the value
|
||||
// beside it may be 36.
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
@@ -26,7 +28,7 @@ function CopyButton({ value }: { value: string }) {
|
||||
// selectable either way, so this needs no error state.
|
||||
}
|
||||
}}
|
||||
className="rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
|
||||
className="shrink-0 rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
|
||||
>
|
||||
{done ? "Copied" : "Copy"}
|
||||
</button>
|
||||
@@ -76,9 +78,13 @@ export function PageHeader({
|
||||
{(record?.length || status) && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
|
||||
{record?.map((f) => (
|
||||
<span key={f.key} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
|
||||
// min-w-0 and break-all because the commonest value here
|
||||
// is a 36-character UUID with a Copy button beside it,
|
||||
// which does not fit a 320px screen as one unbreakable
|
||||
// token and pushed the whole page sideways.
|
||||
<span key={f.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
|
||||
<span className="min-w-0 break-all font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
|
||||
{f.copy && <CopyButton value={f.value} />}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
/*
|
||||
* The surface every screen is built from.
|
||||
*
|
||||
* Before this there were four panel treatments in the app: `rounded border
|
||||
* border-rule bg-panel p-5` with an `<h2 className="text-xl">`, the same thing
|
||||
* with `text-[0.95rem] font-medium`, a bare `<section className="space-y-2">`
|
||||
* with no border at all, and a table wrapper that was a panel in everything but
|
||||
* name. They were all trying to be the same object.
|
||||
*
|
||||
* The header is title-left, meta-right. Meta is the keyed idiom — mono, small,
|
||||
* tracked, dimmed — because it is always a count, a scope or an identifier,
|
||||
* never prose.
|
||||
*/
|
||||
export function Panel({
|
||||
title,
|
||||
meta,
|
||||
actions,
|
||||
tone,
|
||||
children,
|
||||
bodyless,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
meta?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
/** Draws the panel's own border in a state colour. For a panel that IS the warning. */
|
||||
tone?: "warn" | "expired";
|
||||
children: React.ReactNode;
|
||||
/** Skip the padded body — for a panel whose content is a full-bleed table. */
|
||||
bodyless?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const head = title || meta || actions;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"grid overflow-hidden rounded border bg-panel",
|
||||
tone === "warn" ? "border-warn" : tone === "expired" ? "border-expired" : "border-rule",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{head && (
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
|
||||
{title && <h2 className="text-[0.95rem] font-bold tracking-[-0.01em]">{title}</h2>}
|
||||
<div className="flex items-center gap-3">
|
||||
{meta && <span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{meta}</span>}
|
||||
{actions}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
{bodyless ? children : <div className="grid gap-3.5 p-4">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* An aside that is part of the argument rather than beside it: the consequence
|
||||
* of the action on screen, or the constraint the reader is about to hit. The
|
||||
* left rule carries the tone, so the note reads as annotation and never as a
|
||||
* second panel competing with the one it sits in.
|
||||
*/
|
||||
export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={clsx(
|
||||
"rounded border border-rule border-l-[3px] bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2",
|
||||
tone === "warn" ? "border-l-warn" : tone === "expired" ? "border-l-expired" : "border-l-accent",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* An empty screen is an invitation to act. Every one of these says what the
|
||||
* thing is before offering to make one — "No licences match those filters" on
|
||||
* its own tells someone the filter worked, not what to do about it.
|
||||
*/
|
||||
export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid justify-items-center gap-2 px-5 py-12 text-center">
|
||||
<p className="text-[1rem] font-bold">{title}</p>
|
||||
{body && <p className="max-w-[46ch] text-[0.86rem] text-ink-2">{body}</p>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export interface PlanChoice {
|
||||
tier: Tier;
|
||||
@@ -159,7 +160,7 @@ export default function PlanConfigurator({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
|
||||
<span>{featureLabel(key)}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{priced ? "paid add-on" : "included"}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const TONE = {
|
||||
expired: "border-l-expired text-expired",
|
||||
@@ -16,7 +17,11 @@ export function Queue({
|
||||
title: string;
|
||||
count: number;
|
||||
tone: keyof typeof TONE;
|
||||
items: { label: string; href: string; meta: string }[];
|
||||
/* `meta` is a node rather than a string so a queue about time can carry the
|
||||
* term measurement itself. A tier name told the reader what the instance
|
||||
* was; the queue is sorted by how soon it lapses, and that was the one
|
||||
* figure the row did not show. */
|
||||
items: { label: string; href: string; meta: ReactNode }[];
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
@@ -38,12 +43,12 @@ export function Queue({
|
||||
{items.map((i) => (
|
||||
<li
|
||||
key={i.href}
|
||||
className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
|
||||
className="flex items-center justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
|
||||
>
|
||||
<Link href={i.href} className="text-accent underline">
|
||||
<Link href={i.href} className="truncate text-accent underline">
|
||||
{i.label}
|
||||
</Link>
|
||||
<span className="tabular-nums">{i.meta}</span>
|
||||
<span className="shrink-0 tabular-nums">{i.meta}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -6,6 +6,18 @@ import { Field } from "./Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/*
|
||||
* The relink control, and only the control.
|
||||
*
|
||||
* It used to carry its own heading and its own "N of M relinks left this term"
|
||||
* line. It now sits inside the Moves panel, which already says both — a panel
|
||||
* titled Moves with "2 of 3 used" in its header, wrapping a section headed
|
||||
* "Moved to a new server?" that says "1 of 3 relinks left", is the same fact
|
||||
* told twice in two different directions.
|
||||
*
|
||||
* The exhausted case still lives here rather than in the caller: it is the
|
||||
* reason the button is disabled, so it belongs beside the button.
|
||||
*/
|
||||
export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string; used: number; max: number; onRelink: (newId: string) => void; error?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
@@ -13,18 +25,25 @@ export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string
|
||||
const exhausted = remaining === 0;
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 border-t border-rule-soft pt-5">
|
||||
<h2 className="text-xl">Moved to a new server?</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">Relinking issues a replacement licence for the new install, covering the rest of your current term.</p>
|
||||
{open && !exhausted && <Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />}
|
||||
<div className="grid gap-3">
|
||||
{open && !exhausted && (
|
||||
<Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="line" disabled={exhausted || (open && !UUID_RE.test(value.trim()))} onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}>
|
||||
Relink to a new install
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
|
||||
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
|
||||
>
|
||||
Move to another install
|
||||
</Button>
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
{exhausted ? "You have used every relink for this term contact support and we will sort it out." : `${remaining} of ${max} relinks left this term`}
|
||||
</span>
|
||||
{exhausted ? (
|
||||
<span className="text-[0.82rem] text-ink-3">You have used every move for this term — contact support and we will sort it out.</span>
|
||||
) : (
|
||||
open && <span className="text-[0.82rem] text-ink-3">Relinking issues a replacement licence covering the rest of your current term.</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Field } from "./Field";
|
||||
import { Note } from "./Panel";
|
||||
import { ApiError, type RenameResult } from "@/lib/api";
|
||||
import { baseSlug, hostFor, slugError } from "@/lib/slug";
|
||||
|
||||
/*
|
||||
* The rename control, and only the control — the same shape as RelinkPanel: an
|
||||
* input that expands in place rather than a modal, because this app has no modal
|
||||
* and one action with one field does not need one.
|
||||
*
|
||||
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
|
||||
* disagree with the server; the 409 that comes back is the answer that counts.
|
||||
*
|
||||
* movesHost is what separates a rename that moves a DNS host from one that only
|
||||
* changes a label. Self-hosted instances and unprovisioned cloud placeholders
|
||||
* have no address, so every word about old links breaking and signing in again
|
||||
* is false for them — and a preview host they will never live at is worse than
|
||||
* no preview at all.
|
||||
*/
|
||||
export function RenamePanel({
|
||||
currentName,
|
||||
currentSlug,
|
||||
movesHost,
|
||||
onRename,
|
||||
}: {
|
||||
currentName: string;
|
||||
currentSlug: string;
|
||||
movesHost: boolean;
|
||||
onRename: (name: string) => Promise<RenameResult>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(currentName);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<RenameResult | undefined>();
|
||||
|
||||
const name = value.trim();
|
||||
const derived = baseSlug(name);
|
||||
const invalid = slugError(name);
|
||||
// A cosmetic edit that lands on the same slug is still a rename worth doing —
|
||||
// the name is what the customer reads. Only an empty or unchanged name is
|
||||
// nothing to submit.
|
||||
const unchanged = name === currentName.trim();
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await onRename(name);
|
||||
setDone(res);
|
||||
setOpen(false);
|
||||
// The input is prefilled with the current name, and the current name
|
||||
// is now this one. Leaving the old text in would make the next open
|
||||
// look like an edit already in progress.
|
||||
setValue(res.name);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The note sits ABOVE the control rather than replacing it. A rename is not
|
||||
// a one-shot action — a customer who mistypes the new name needs the panel
|
||||
// back, and returning early here left them with a success message and no way
|
||||
// to correct it short of a reload.
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{done &&
|
||||
(movesHost ? (
|
||||
<Note tone="warn">
|
||||
<span className="grid gap-2">
|
||||
<span>
|
||||
This instance is now <strong>{done.name}</strong>, at{" "}
|
||||
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and
|
||||
your sign-in does not follow it — you will need to sign in again there.
|
||||
</span>
|
||||
<a
|
||||
href={done.login_url || `https://${hostFor(done.slug)}`}
|
||||
className="justify-self-start font-mono text-[0.78rem] text-accent underline"
|
||||
>
|
||||
Open {hostFor(done.slug)} →
|
||||
</a>
|
||||
</span>
|
||||
</Note>
|
||||
) : (
|
||||
<Note tone="warn">
|
||||
This instance is now <strong>{done.name}</strong>.
|
||||
</Note>
|
||||
))}
|
||||
{open && (
|
||||
<Field
|
||||
label="Instance name"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error ?? (name ? invalid : undefined)}
|
||||
hint={
|
||||
movesHost && name && !invalid ? (
|
||||
<>
|
||||
Moves to <span className="font-mono">{hostFor(derived)}</span>
|
||||
{derived === currentSlug && " — the address does not change"}
|
||||
</>
|
||||
) : (
|
||||
"Letters and digits; everything else becomes a hyphen."
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
|
||||
onClick={() => (open ? submit() : setOpen(true))}
|
||||
>
|
||||
{busy ? "Renaming…" : "Rename instance"}
|
||||
</Button>
|
||||
{open && movesHost && (
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import clsx from "clsx";
|
||||
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||
|
||||
/*
|
||||
* One table treatment for the whole console.
|
||||
*
|
||||
* There were four: billing, licences, accounts and catalogue each wrote their
|
||||
* own thead, and they disagreed about the head's type size, its tracking,
|
||||
* whether it sat on --panel-2, and whether numbers were tabular. Catalogue's
|
||||
* heads were sentence-case body text. A registry whose columns are set four
|
||||
* ways does not read as one product.
|
||||
*
|
||||
* The head is the keyed idiom — mono, small, uppercase, widely tracked — which
|
||||
* is what a column head is: a key above a value, exactly as the record line is
|
||||
* a key beside one.
|
||||
*
|
||||
* MOBILE. `stack` collapses the table into one card per row below sm, each cell
|
||||
* becoming a label/value pair drawn from TD's `label`. The variants below hang
|
||||
* off a `stacked` class on the <table>, so a table that does not opt in is
|
||||
* untouched at every width.
|
||||
*
|
||||
* It is opt-in rather than automatic because a stacked row whose cells have no
|
||||
* labels is worse than a scrolling one — the values lose the only thing naming
|
||||
* them. Customer screens stack; the staff console's wide registry tables scroll
|
||||
* sideways instead, which is the right trade for eight columns read at a desk.
|
||||
*/
|
||||
|
||||
const STACK = "max-sm:[.stacked_&]:block";
|
||||
|
||||
export function Table({ stack, className, children, ...props }: HTMLAttributes<HTMLTableElement> & { stack?: boolean }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={clsx("w-full border-collapse text-left text-[0.86rem]", stack && "stacked max-sm:block", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function THead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<thead className={clsx("border-b border-rule", "max-sm:[.stacked_&]:hidden", className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function TBody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<tbody className={clsx(STACK, "max-sm:[.stacked_&]:space-y-3 max-sm:[.stacked_&]:p-3", className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
export function TR({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return (
|
||||
<tr
|
||||
className={clsx(
|
||||
"border-b border-rule-soft last:border-0 hover:bg-panel-2",
|
||||
STACK,
|
||||
// Plain bg-panel-2, not an opacity modifier: this app's tokens
|
||||
// are whole colours rather than RGB channels, so `/40` has
|
||||
// nothing to drop an alpha into. web/ stores channels precisely
|
||||
// because it leans on those modifiers; this one must not.
|
||||
"max-sm:[.stacked_&]:rounded max-sm:[.stacked_&]:border max-sm:[.stacked_&]:border-rule max-sm:[.stacked_&]:bg-panel-2 max-sm:[.stacked_&]:p-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface CellProps {
|
||||
/** Right-aligns the cell. For quantities and money, which read down the column. */
|
||||
numeric?: boolean;
|
||||
}
|
||||
|
||||
export function TH({ className, numeric, children, ...props }: ThHTMLAttributes<HTMLTableCellElement> & CellProps) {
|
||||
return (
|
||||
<th
|
||||
className={clsx(
|
||||
"whitespace-nowrap px-4 py-2.5 font-mono text-[0.62rem] font-normal uppercase tracking-[0.13em] text-ink-3",
|
||||
numeric && "text-right",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function TD({ className, numeric, label, children, ...props }: TdHTMLAttributes<HTMLTableCellElement> & CellProps & { label?: string }) {
|
||||
return (
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-3 align-middle",
|
||||
numeric && "text-right tabular-nums",
|
||||
// Stacked, a cell is a label above its value and the right
|
||||
// alignment that made a money column read down the page is
|
||||
// meaningless, so it is dropped.
|
||||
STACK,
|
||||
"max-sm:[.stacked_&]:px-0 max-sm:[.stacked_&]:py-1 max-sm:[.stacked_&]:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label && (
|
||||
<span className="mb-0.5 hidden font-mono text-[0.6rem] uppercase tracking-[0.13em] text-ink-3 max-sm:[.stacked_&]:block">{label}</span>
|
||||
)}
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/** The secondary line under a cell's main value — an ID, a deployment, a date. */
|
||||
export function Sub({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-[0.78rem] text-ink-3">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import clsx from "clsx";
|
||||
import { daysRemaining, formatDate, type LicenceState } from "@/lib/format";
|
||||
|
||||
/*
|
||||
* A licence's life as a measured line: issued at the left, expiry at the right,
|
||||
* today as a notch, the part you have not got yet hatched.
|
||||
*
|
||||
* This replaces a 1px progress rule and a "Renews 19 Aug 2026" caption. The
|
||||
* date is still there, but a date alone makes the reader do the arithmetic that
|
||||
* is the only question this product is ever asked — when does this stop
|
||||
* working. The bar answers it before they read a word.
|
||||
*
|
||||
* The fill takes the state's colour, so the same vocabulary the pill uses
|
||||
* carries through. State is never colour alone here either: the remaining span
|
||||
* is hatched rather than tinted, the notch is a hard edge, and the days-left
|
||||
* figure is written out.
|
||||
*/
|
||||
|
||||
const TONE: Record<LicenceState, string> = {
|
||||
valid: "text-valid",
|
||||
warn: "text-warn",
|
||||
expired: "text-expired",
|
||||
none: "text-accent",
|
||||
};
|
||||
|
||||
function span(issuedAt: string, expiresAt: string) {
|
||||
const start = new Date(issuedAt).getTime();
|
||||
const end = new Date(expiresAt).getTime();
|
||||
const total = end - start;
|
||||
// A licence issued and expiring at the same instant is not a real record,
|
||||
// but it must not divide by zero on the way to being rendered.
|
||||
if (!Number.isFinite(total) || total <= 0) return 100;
|
||||
const elapsed = Date.now() - start;
|
||||
return Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||||
}
|
||||
|
||||
export function TermBar({
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
state,
|
||||
className,
|
||||
}: {
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
state: LicenceState;
|
||||
className?: string;
|
||||
}) {
|
||||
const pct = span(issuedAt, expiresAt);
|
||||
const days = daysRemaining(expiresAt);
|
||||
const expired = days <= 0;
|
||||
|
||||
const remaining = expired
|
||||
? `Expired ${Math.abs(days)} ${Math.abs(days) === 1 ? "day" : "days"} ago`
|
||||
: `${days} ${days === 1 ? "day" : "days"} left`;
|
||||
|
||||
return (
|
||||
<div className={clsx("grid gap-2", TONE[state], className)}>
|
||||
<div className="relative h-[26px] overflow-hidden rounded-sm border border-rule bg-panel-2">
|
||||
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.16]" style={{ width: `${pct}%` }} />
|
||||
{/* The span still to come, drawn as absence rather than as a
|
||||
second colour: it is the thing being bought. */}
|
||||
<span
|
||||
className="absolute inset-y-0 right-0 bg-[repeating-linear-gradient(45deg,transparent_0_5px,var(--rule-soft)_5px_6px)]"
|
||||
style={{ width: `${100 - pct}%` }}
|
||||
/>
|
||||
<span className="absolute -inset-y-px w-0.5 bg-current" style={{ left: `${pct}%` }} />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* On a phone the three ends stack, and the figure someone actually
|
||||
* came for goes first — wrapping a justify-between row left "9 days
|
||||
* left" marooned between two dates in the middle of the stack.
|
||||
*/}
|
||||
<div className="grid gap-1 sm:flex sm:flex-wrap sm:items-baseline sm:justify-between sm:gap-x-4">
|
||||
<span className="order-1 font-mono text-[0.74rem] font-bold tabular-nums sm:order-2">{remaining}</span>
|
||||
<span className="order-2 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3 sm:order-1">Issued {formatDate(issuedAt)}</span>
|
||||
<span className="order-3 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Expires {formatDate(expiresAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* The same measurement at 56px, for a row in a ledger. Licences, Billing and
|
||||
* the staff expiry queue are all lists of terms, and a list of dates cannot be
|
||||
* scanned for "which of these is nearly out" — a list of bars can.
|
||||
*
|
||||
* It carries a text alternative rather than a title: the row it sits in is
|
||||
* being read, not hovered.
|
||||
*/
|
||||
export function TermSpark({ issuedAt, expiresAt, state }: { issuedAt: string; expiresAt: string; state: LicenceState }) {
|
||||
const pct = span(issuedAt, expiresAt);
|
||||
const days = daysRemaining(expiresAt);
|
||||
|
||||
return (
|
||||
<span className={clsx("inline-flex items-center gap-2", TONE[state])}>
|
||||
<span aria-hidden className="relative inline-block h-[9px] w-14 overflow-hidden rounded-sm border border-rule bg-panel-2 align-middle">
|
||||
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.45]" style={{ width: `${pct}%` }} />
|
||||
<span className="absolute inset-y-0 w-px bg-current" style={{ left: `${pct}%` }} />
|
||||
</span>
|
||||
<span className="font-mono text-[0.72rem] tabular-nums">{days <= 0 ? `−${Math.abs(days)}d` : `${days}d`}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+26
-12
@@ -129,6 +129,9 @@ export interface Instance {
|
||||
status: InstanceStatus;
|
||||
current_license?: string;
|
||||
relink_count: number;
|
||||
/** Cloud only, and only until the paid checkout provisions the real row. */
|
||||
placeholder?: boolean;
|
||||
renamed_at?: string;
|
||||
inject_failed_at?: string | null;
|
||||
notices_sent?: string[];
|
||||
created_at: string;
|
||||
@@ -247,13 +250,13 @@ export interface Entitlement {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CustomerUser {
|
||||
user_id: string;
|
||||
account_id: string;
|
||||
email: string;
|
||||
verified_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
/*
|
||||
* Staff and customer screens read the SAME customer_users row, so they share one
|
||||
* type. There used to be a second, narrower CustomerUser for the staff side; it
|
||||
* silently stopped matching the moment account_role was added to the model, and
|
||||
* a subset type cannot warn about a field it never claimed to have.
|
||||
*/
|
||||
export type CustomerUser = AccountUser;
|
||||
|
||||
export interface AuditEntry {
|
||||
actor: string;
|
||||
@@ -288,6 +291,14 @@ export interface StaffInstanceResponse {
|
||||
injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
|
||||
}
|
||||
|
||||
export interface RenameResult {
|
||||
instance_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
/** Empty when APP_LOGIN_URL is unset on the server. */
|
||||
login_url?: string;
|
||||
}
|
||||
|
||||
// --- calls ---------------------------------------------------------------
|
||||
|
||||
export const api = {
|
||||
@@ -306,6 +317,8 @@ export const api = {
|
||||
post<Instance>("/api/instances/link", { instance_id, name }),
|
||||
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
|
||||
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/instances/${id}/name`, { name }),
|
||||
// Self-hosted Free: issue the licence on an already-linked instance.
|
||||
claimFree: (id: string) => post<License>(`/api/instances/${id}/claim-free`, {}),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
@@ -317,8 +330,10 @@ export const api = {
|
||||
entitlement: (id: string) =>
|
||||
req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`),
|
||||
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
|
||||
createSelfHosted: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
|
||||
// Paid self-hosted: links (or reuses) the install's real UUID, which the
|
||||
// checkout then names. There is no placeholder to claim afterwards.
|
||||
createSelfHostedCheckout: (instance_id: string, name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { instance_id, name }),
|
||||
// Paid cloud: provisions the real instance the paid webhook then licenses.
|
||||
createCloudCheckout: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/cloud", { name }),
|
||||
@@ -326,9 +341,6 @@ export const api = {
|
||||
id: string,
|
||||
body: { tier: Tier; term: Term; servers: number; features: string[] },
|
||||
) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
|
||||
claimLink: (placeholderId: string, instance_id: string) =>
|
||||
post<{ instance_id: string; warning?: string }>(
|
||||
`/api/instances/${placeholderId}/claim-link`, { instance_id }),
|
||||
billingPortal: () => post<{ url: string }>("/api/billing/portal"),
|
||||
|
||||
accountUsers: () => req<AccountUser[]>("/api/account/users"),
|
||||
@@ -368,6 +380,8 @@ export const api = {
|
||||
post<License>(`/api/staff/instances/${id}/issue`, payload),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
|
||||
licenses: (params?: Record<string, string>) =>
|
||||
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
|
||||
plans: () => req<Plan[]>("/api/staff/plans"),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Human wording for licence feature keys.
|
||||
*
|
||||
* One place, because there were two and they disagreed: the staff configurator
|
||||
* rendered every key that was not "console" as "Single sign-on", so adding a
|
||||
* third feature silently mislabelled the checkbox that grants it. A map with a
|
||||
* fallback degrades to the raw key, which is ugly but never wrong.
|
||||
*
|
||||
* Keys must match shared/license/license.go. */
|
||||
export const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
vuln_scanning: "Vulnerability scanning",
|
||||
};
|
||||
|
||||
export const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
vuln_scanning: "Package inventory matched against distribution security advisories",
|
||||
};
|
||||
|
||||
export function featureLabel(key: string): string {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
|
||||
export function featureDesc(key: string): string {
|
||||
return FEATURE_DESC[key] ?? "";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the
|
||||
* host a rename would move an instance to while the customer types.
|
||||
*
|
||||
* It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it
|
||||
* must change in the same commit as the Go one — the same hazard as
|
||||
* web/lib/targets.ts. The preview is a courtesy; the server's 409 is the
|
||||
* boundary, and the two are allowed to disagree without anything breaking.
|
||||
*/
|
||||
|
||||
/** Mirrors provision.MinSlugLength / MaxSlugLength. */
|
||||
export const MIN_SLUG_LENGTH = 3;
|
||||
export const MAX_SLUG_LENGTH = 40;
|
||||
|
||||
/** Mirrors provision.ReservedSlugs. */
|
||||
const RESERVED = new Set([
|
||||
"www", "api", "app", "admin", "auth",
|
||||
"install", "static", "_next", "default",
|
||||
]);
|
||||
|
||||
/*
|
||||
* The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the
|
||||
* customer instance page; those predate this file and are left alone rather than
|
||||
* refactored under a rename change.
|
||||
*/
|
||||
export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk";
|
||||
|
||||
/** Mirrors provision.Slugify. */
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Mirrors provision.BaseSlug's truncation. */
|
||||
export function baseSlug(name: string): string {
|
||||
return slugify(name).slice(0, MAX_SLUG_LENGTH);
|
||||
}
|
||||
|
||||
/** The reason a name cannot become a slug, or undefined when it can. */
|
||||
export function slugError(name: string): string | undefined {
|
||||
const base = slugify(name);
|
||||
if (base.length < MIN_SLUG_LENGTH) {
|
||||
return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`;
|
||||
}
|
||||
if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) {
|
||||
return "That name is reserved.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The host an instance on this slug is reached at. */
|
||||
export function hostFor(slug: string): string {
|
||||
return `${slug}.${INSTANCE_DOMAIN}`;
|
||||
}
|
||||
@@ -80,7 +80,11 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
|
||||
return resp.AgentToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
|
||||
// SyncKeys returns the whole response rather than just the keys: the poll now
|
||||
// also carries CollectPackages, and a second RPC purely to learn one boolean
|
||||
// would be a message every 30 seconds for a value that changes at most when a
|
||||
// licence does.
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) (*pb.SyncResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -92,7 +96,34 @@ func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.PublicKeys, nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ReportPackages sends a package report and returns whether the server wants
|
||||
// the full list. Given a longer deadline than the other unary calls because the
|
||||
// full body is ~150KB on a slow link.
|
||||
func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.ReportPackages(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.NeedFull, nil
|
||||
}
|
||||
|
||||
// ReportWorkloads sends a workload report and returns whether the server wants
|
||||
// the full list.
|
||||
func (c *Client) ReportWorkloads(req *pb.ReportWorkloadsRequest) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.ReportWorkloads(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.NeedFull, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
|
||||
|
||||
@@ -30,6 +30,45 @@ type SyncRequest struct {
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
// CollectPackages tells the agent whether this instance's licence grants
|
||||
// vulnerability scanning. Absent decodes as false, which is the safe
|
||||
// direction: an older server leaves agents collecting nothing.
|
||||
CollectPackages bool `json:"collect_packages,omitempty"`
|
||||
}
|
||||
|
||||
type OSRelease struct {
|
||||
Family string `json:"family"`
|
||||
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
|
||||
// versions for the same CVE, so a scan without it is guesswork.
|
||||
VersionId string `json:"version_id"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
}
|
||||
|
||||
type InstalledPackage struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Epoch int32 `json:"epoch,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
|
||||
// against "openssl" covers libssl3, openssl and libssl-dev.
|
||||
SourceName string `json:"source_name,omitempty"`
|
||||
}
|
||||
|
||||
// ReportPackagesRequest carries a server's installed package set.
|
||||
//
|
||||
// The agent calls twice at most: first with Packages empty, offering only the
|
||||
// hash. If the server already holds it, NeedFull is false and the ~150KB body
|
||||
// is never sent.
|
||||
type ReportPackagesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
Os OSRelease `json:"os"`
|
||||
Packages []InstalledPackage `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
type ReportPackagesResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
@@ -167,6 +206,10 @@ type ServerCommand struct {
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
|
||||
Ping *PingCmd `json:"ping,omitempty"`
|
||||
|
||||
RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"`
|
||||
ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"`
|
||||
WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,omitempty"`
|
||||
}
|
||||
|
||||
// PingCmd is a server-originated liveness beat. It carries nothing and expects
|
||||
@@ -204,6 +247,8 @@ type AgentMessage struct {
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
|
||||
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
@@ -337,6 +382,8 @@ type VantageClient interface {
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
|
||||
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
@@ -396,6 +443,14 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
|
||||
out := new(ReportPackagesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
|
||||
out := new(InventoryReportResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Workload registry messages. Hand-written like the rest of this package: the
|
||||
// .proto is the contract, this file is the Go side of it, and the two must be
|
||||
// changed together.
|
||||
|
||||
// Workload is one container or one systemd unit.
|
||||
type Workload struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Restarts int32 `json:"restarts,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
|
||||
Protected bool `json:"protected,omitempty"`
|
||||
}
|
||||
|
||||
// ReportWorkloadsRequest carries what a server is running.
|
||||
//
|
||||
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
|
||||
// with Workloads empty, and resends with the body only if NeedFull is set.
|
||||
type ReportWorkloadsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
DockerOk bool `json:"docker_ok"`
|
||||
DockerError string `json:"docker_error,omitempty"`
|
||||
SystemdOk bool `json:"systemd_ok"`
|
||||
SystemdError string `json:"systemd_error,omitempty"`
|
||||
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
|
||||
// Full marks the second call. It is not inferred from an empty Workloads
|
||||
// slice: a host running nothing sends an empty list as its full report.
|
||||
Full bool `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
type ReportWorkloadsResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
|
||||
// immediately through ReportWorkloads, so there is exactly one writer for the
|
||||
// server_workloads collection rather than two arriving by different routes.
|
||||
type RefreshWorkloadsCmd struct{}
|
||||
|
||||
type ControlWorkloadCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Action string `json:"action"` // start | stop | restart
|
||||
}
|
||||
|
||||
type WorkloadLogsCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Tail int32 `json:"tail,omitempty"`
|
||||
}
|
||||
|
||||
type WorkloadLogsResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
|
||||
out := new(ReportWorkloadsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OSRelease identifies the distribution well enough to select an advisory
|
||||
// feed. VersionID is not optional: Ubuntu 22.04 and 24.04 publish different
|
||||
// fixed versions for the same CVE.
|
||||
type OSRelease struct {
|
||||
Family string
|
||||
VersionID string
|
||||
Arch string
|
||||
}
|
||||
|
||||
// ParseOSRelease reads the os-release format: KEY=value, one per line, with
|
||||
// values optionally quoted, and # comments.
|
||||
//
|
||||
// The quote stripping handles both ID=ubuntu and ID="rocky", which real
|
||||
// distributions both emit.
|
||||
func ParseOSRelease(r io.Reader) (OSRelease, error) {
|
||||
out := OSRelease{Arch: runtime.GOARCH}
|
||||
sc := bufio.NewScanner(r)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "ID":
|
||||
out.Family = strings.ToLower(val)
|
||||
case "VERSION_ID":
|
||||
out.VersionID = val
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return OSRelease{}, err
|
||||
}
|
||||
if out.Family == "" {
|
||||
return OSRelease{}, errors.New("os-release has no ID")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DetectOS reads /etc/os-release.
|
||||
func DetectOS() (OSRelease, error) {
|
||||
f, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return OSRelease{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
return ParseOSRelease(f)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
const collectTimeout = 2 * time.Minute
|
||||
|
||||
// Collect enumerates installed packages. Linux only: Windows agents are
|
||||
// second-class by design, and vulnerability scanning there needs a different
|
||||
// source, a different collector and a different matcher, all out of scope.
|
||||
//
|
||||
// The format strings below are raw string literals on purpose. The "\t" and
|
||||
// "\n" reach dpkg-query and rpm as two characters each, and those tools do the
|
||||
// interpreting themselves — Go must not consume the escapes first.
|
||||
func Collect() (OSRelease, []Package, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
osrel, err := DetectOS()
|
||||
if err != nil {
|
||||
return OSRelease{}, nil, fmt.Errorf("detect os: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
|
||||
defer cancel()
|
||||
|
||||
switch {
|
||||
case have("dpkg-query"):
|
||||
out, err := run(ctx, "dpkg-query", "-W", "-f",
|
||||
`${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n`)
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseDpkg(out), nil
|
||||
|
||||
case have("rpm"):
|
||||
out, err := run(ctx, "rpm", "-qa", "--qf",
|
||||
`%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`)
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseRPM(out), nil
|
||||
|
||||
case have("apk"):
|
||||
out, err := run(ctx, "apk", "info", "-v")
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseAPK(out), nil
|
||||
|
||||
default:
|
||||
return osrel, nil, fmt.Errorf("no supported package manager found")
|
||||
}
|
||||
}
|
||||
|
||||
func have(bin string) bool {
|
||||
_, err := exec.LookPath(bin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, name, args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Package is one installed package as the distribution reports it. Version is
|
||||
// the distribution's own version string, verbatim — never normalised, because
|
||||
// the advisory feeds are keyed on exactly this form.
|
||||
type Package struct {
|
||||
Name string
|
||||
Version string
|
||||
Epoch int
|
||||
Arch string
|
||||
SourceName string
|
||||
}
|
||||
|
||||
// ParseDpkg reads tab-separated output of
|
||||
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n'
|
||||
//
|
||||
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
|
||||
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
|
||||
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
|
||||
// alone finds one of the three.
|
||||
//
|
||||
// The fifth column is why "rc" packages do not appear. dpkg-query -W lists
|
||||
// every package dpkg knows about, including ones removed with their config
|
||||
// files left behind — a host that has upgraded its kernel a dozen times reports
|
||||
// a dozen old linux-modules versions that are not on disk, and the oldest of
|
||||
// them sorts first and reads as the installed version. Only "installed" is
|
||||
// installed. An empty status means dpkg did not understand the field, in which
|
||||
// case the line is kept rather than the whole inventory silently vanishing.
|
||||
func ParseDpkg(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
if len(f) > 4 {
|
||||
if s := strings.TrimSpace(f[4]); s != "" && s != "installed" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
|
||||
if len(f) > 3 && f[3] != "" {
|
||||
p.SourceName = f[3]
|
||||
} else {
|
||||
p.SourceName = p.Name
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// ParseRPM reads tab-separated output of
|
||||
// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n'
|
||||
func ParseRPM(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
epoch := 0
|
||||
// rpm prints "(none)" rather than omitting the field when a package has
|
||||
// no epoch. That must become 0, not fail the line.
|
||||
if f[1] != "" && f[1] != "(none)" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
epoch = n
|
||||
}
|
||||
}
|
||||
p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]}
|
||||
if len(f) > 4 {
|
||||
p.SourceName = srcRPMName(f[4])
|
||||
}
|
||||
if p.SourceName == "" {
|
||||
p.SourceName = p.Name
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping
|
||||
// the trailing ".src.rpm" and then the version and release segments, which are
|
||||
// the last two hyphen-separated fields.
|
||||
func srcRPMName(s string) string {
|
||||
s = strings.TrimSuffix(s, ".src.rpm")
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) <= 2 {
|
||||
return s
|
||||
}
|
||||
return strings.Join(parts[:len(parts)-2], "-")
|
||||
}
|
||||
|
||||
// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line.
|
||||
// Alpine has no separate source package, so SourceName mirrors Name.
|
||||
func ParseAPK(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, version := splitAPK(line)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// splitAPK finds the version boundary from the RIGHT. The version is always the
|
||||
// last two hyphen-separated fields ("<version>-r<rev>"), which is reliable
|
||||
// where scanning from the left is not: package names legitimately contain
|
||||
// digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be
|
||||
// found by looking for the first digit.
|
||||
func splitAPK(s string) (name, version string) {
|
||||
last := strings.LastIndex(s, "-")
|
||||
if last <= 0 {
|
||||
return "", ""
|
||||
}
|
||||
prev := strings.LastIndex(s[:last], "-")
|
||||
if prev <= 0 {
|
||||
return "", ""
|
||||
}
|
||||
return s[:prev], s[prev+1:]
|
||||
}
|
||||
|
||||
// Hash fingerprints a package set so an unchanged set never has to be sent.
|
||||
//
|
||||
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
|
||||
// and an ordering-sensitive hash would resend the full ~150KB list every hour
|
||||
// for no reason — a cost visible only as traffic.
|
||||
func Hash(pkgs []Package) string {
|
||||
lines := make([]string, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
h := sha256.New()
|
||||
for _, l := range lines {
|
||||
h.Write([]byte(l))
|
||||
h.Write([]byte("\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
|
||||
)
|
||||
|
||||
// collectPackagesFlag is written by the 30s key poll and read by the hourly
|
||||
// package loop — two different goroutines, hence the atomic.
|
||||
//
|
||||
// It defaults to false, so an agent that has not yet completed a poll, or is
|
||||
// talking to a server too old to send the field, collects nothing. Off is the
|
||||
// safe default: collecting without a licence costs the customer storage they
|
||||
// are not paying for.
|
||||
var collectPackagesFlag atomic.Bool
|
||||
|
||||
// firstPoll closes once a SyncKeys response has set the flag above.
|
||||
//
|
||||
// Without it the boot-time package report loses a race it can only lose: the
|
||||
// hourly loop starts before the first poll, reads a flag that is still false by
|
||||
// construction, and skips — so a freshly installed agent reports no packages for
|
||||
// an hour and the server shows nothing to scan.
|
||||
// How long the boot package report waits for that first poll. Two poll
|
||||
// intervals plus slack: long enough to cover one failed attempt, short enough
|
||||
// that a dead control plane does not hold the OS-update report hostage.
|
||||
const firstPollWait = 90 * time.Second
|
||||
|
||||
var (
|
||||
firstPoll = make(chan struct{})
|
||||
firstPollOnce sync.Once
|
||||
)
|
||||
|
||||
func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
|
||||
|
||||
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
|
||||
// bounded because this loop also reports OS updates, which do not depend on the
|
||||
// flag at all — a control plane that cannot be polled must not silence those too.
|
||||
func waitFirstPoll(ctx context.Context, limit time.Duration) {
|
||||
t := time.NewTimer(limit)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-firstPoll:
|
||||
case <-t.C:
|
||||
log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
|
||||
|
||||
// reportPackages offers a hash of the installed package set and sends the full
|
||||
// list only if the server does not already hold it.
|
||||
//
|
||||
// It runs on the same hourly cadence as the update check because a package set
|
||||
// changes on roughly the same schedule, and reusing that loop means one timer
|
||||
// rather than two.
|
||||
func reportPackages(client *grpcclient.Client, cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
if !collectPackagesEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
osrel, pkgs, err := packages.Collect()
|
||||
if err != nil {
|
||||
log.Printf("package collection error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
pbOS := pb.OSRelease{
|
||||
Family: osrel.Family,
|
||||
VersionId: osrel.VersionID,
|
||||
Arch: osrel.Arch,
|
||||
}
|
||||
hash := packages.Hash(pkgs)
|
||||
|
||||
// The offer: hash only, no body. On an unchanged host this is the whole
|
||||
// exchange, which is the point of the handshake.
|
||||
needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
Os: pbOS,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ReportPackages offer error: %v", err)
|
||||
return
|
||||
}
|
||||
if !needFull {
|
||||
return
|
||||
}
|
||||
|
||||
pbPkgs := make([]pb.InstalledPackage, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
pbPkgs[i] = pb.InstalledPackage{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Epoch: int32(p.Epoch),
|
||||
Arch: p.Arch,
|
||||
SourceName: p.SourceName,
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := client.ReportPackages(&pb.ReportPackagesRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
Os: pbOS,
|
||||
Packages: pbPkgs,
|
||||
}); err != nil {
|
||||
log.Printf("ReportPackages full error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d installed packages", len(pkgs))
|
||||
}
|
||||
@@ -70,6 +70,8 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
|
||||
go runInventory(ctx, cfg)
|
||||
|
||||
go runWorkloads(ctx, cfg)
|
||||
|
||||
go monitors.Run(ctx, cfg)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
@@ -92,11 +94,19 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
}
|
||||
|
||||
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
|
||||
resp, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SyncKeys: %w", err)
|
||||
}
|
||||
|
||||
// Stored atomically: the hourly package loop reads this from another
|
||||
// goroutine. Absent on the wire decodes as false, so an older server leaves
|
||||
// collection off rather than on.
|
||||
collectPackagesFlag.Store(resp.CollectPackages)
|
||||
markFirstPoll()
|
||||
|
||||
desired := resp.PublicKeys
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil
|
||||
}
|
||||
@@ -339,6 +349,15 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.OpenProxy != nil {
|
||||
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
|
||||
}
|
||||
if cmd.RefreshWorkloads != nil {
|
||||
go handleRefreshWorkloads(cfg)
|
||||
}
|
||||
if cmd.ControlWorkload != nil {
|
||||
go handleControlWorkload(send, cfg, cmd.CommandId, cmd.ControlWorkload)
|
||||
}
|
||||
if cmd.WorkloadLogs != nil {
|
||||
go handleWorkloadLogs(send, cfg, cmd.CommandId, cmd.WorkloadLogs)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
@@ -395,8 +414,17 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d available OS updates", len(pkgs))
|
||||
|
||||
// Same hourly cadence, same connection. A package set changes on
|
||||
// roughly the schedule available updates do, so this needs no timer of
|
||||
// its own.
|
||||
reportPackages(client, cfg)
|
||||
}
|
||||
|
||||
// The boot round only: after this the flag has long been set, and every
|
||||
// later tick is an hour past a poll that runs every 30s.
|
||||
waitFirstPoll(ctx, firstPollWait)
|
||||
|
||||
doCheck()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/workloads"
|
||||
)
|
||||
|
||||
// workloadInterval is the report cadence. Sixty seconds is affordable because
|
||||
// an unchanged list costs one small offer message, not the body.
|
||||
const workloadInterval = 60 * time.Second
|
||||
|
||||
// runWorkloads reports what this host runs, on its own ticker.
|
||||
func runWorkloads(ctx context.Context, cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
|
||||
reportWorkloads(cfg)
|
||||
|
||||
ticker := time.NewTicker(workloadInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reportWorkloads offers a hash of the current workload set and sends the full
|
||||
// list only if the server does not already hold it.
|
||||
//
|
||||
// This is the ONLY writer of the server_workloads collection. RefreshWorkloadsCmd
|
||||
// calls straight into here rather than answering with data of its own.
|
||||
func reportWorkloads(cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
res := workloads.Collect(ctx)
|
||||
hash := workloads.Hash(res.Workloads)
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("workload report dial error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
base := func() *pb.ReportWorkloadsRequest {
|
||||
return &pb.ReportWorkloadsRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
DockerOk: res.DockerOK,
|
||||
DockerError: res.DockerError,
|
||||
SystemdOk: res.SystemdOK,
|
||||
SystemdError: res.SystemdError,
|
||||
}
|
||||
}
|
||||
|
||||
// The offer: hash only, no body. On an unchanged host this is the whole
|
||||
// exchange, which is the point of the handshake.
|
||||
needFull, err := client.ReportWorkloads(base())
|
||||
if err != nil {
|
||||
log.Printf("ReportWorkloads offer error: %v", err)
|
||||
return
|
||||
}
|
||||
if !needFull {
|
||||
return
|
||||
}
|
||||
|
||||
req := base()
|
||||
req.Full = true
|
||||
req.Workloads = make([]pb.Workload, len(res.Workloads))
|
||||
for i, w := range res.Workloads {
|
||||
req.Workloads[i] = pb.Workload{
|
||||
Kind: w.Kind,
|
||||
Id: w.ID,
|
||||
Name: w.Name,
|
||||
State: w.State,
|
||||
Health: w.Health,
|
||||
Image: w.Image,
|
||||
Stack: w.Stack,
|
||||
Ports: w.Ports,
|
||||
Restarts: int32(w.Restarts),
|
||||
Protected: w.Protected,
|
||||
}
|
||||
if !w.StartedAt.IsZero() {
|
||||
req.Workloads[i].StartedAt = w.StartedAt.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := client.ReportWorkloads(req); err != nil {
|
||||
log.Printf("ReportWorkloads error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d workload(s)", len(res.Workloads))
|
||||
}
|
||||
|
||||
// handleRefreshWorkloads makes the agent report immediately. It sends nothing
|
||||
// back beyond the stream ack: the refresh is a nudge, not a channel, so there
|
||||
// is one writer for the collection rather than two.
|
||||
func handleRefreshWorkloads(cfg *config.Config) {
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
|
||||
// handleControlWorkload starts, stops or restarts a workload and answers with
|
||||
// the ordinary CommandResult.
|
||||
//
|
||||
// The agent's own protected check inside workloads.Control is the boundary; the
|
||||
// Protected flag it reports is only there so the UI can grey the button.
|
||||
func handleControlWorkload(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.ControlWorkloadCmd) {
|
||||
err := workloads.Control(context.Background(), cmd.Kind, cmd.Id, cmd.Action)
|
||||
|
||||
res := &pb.CommandResult{CommandId: commandID, Success: err == nil}
|
||||
if err != nil {
|
||||
res.Message = err.Error()
|
||||
log.Printf("workload %s %s failed (cmd=%s): %v", cmd.Action, cmd.Id, commandID, err)
|
||||
} else {
|
||||
res.Message = cmd.Action + " " + cmd.Id + " ok"
|
||||
}
|
||||
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Result: res,
|
||||
})
|
||||
|
||||
// Report straight away on success so the UI's refetch shows the new state
|
||||
// rather than the old one.
|
||||
if err == nil {
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func handleWorkloadLogs(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.WorkloadLogsCmd) {
|
||||
text, truncated, err := workloads.Logs(context.Background(), cmd.Kind, cmd.Id, int(cmd.Tail))
|
||||
res := &pb.WorkloadLogsResult{CommandId: commandID, Text: text, Truncated: truncated}
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
}
|
||||
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
WorkloadLogsResult: res,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrProtected is returned for a workload the agent will not act on.
|
||||
var ErrProtected = errors.New("workload is protected")
|
||||
|
||||
// AgentUnit is the systemd unit this agent runs as.
|
||||
const AgentUnit = "vantage-agent.service"
|
||||
|
||||
// controlTimeout bounds a stop that may never finish on its own. `docker stop`
|
||||
// waits on a container that may ignore SIGTERM, and `systemctl stop` on a unit
|
||||
// with a long TimeoutStopSec blocks for exactly as long as that says. A
|
||||
// timeout must return a real error rather than an ack implying success.
|
||||
const controlTimeout = 90 * time.Second
|
||||
|
||||
// ownContainerID is read once: the container this agent runs in, if any.
|
||||
var ownContainerID = detectOwnContainer()
|
||||
|
||||
var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)
|
||||
|
||||
// detectOwnContainer returns this process's container ID, or "" on a host
|
||||
// install. The agent is normally a systemd service, so "" is the common case;
|
||||
// this exists so containerising it later cannot silently remove the guard.
|
||||
func detectOwnContainer() string {
|
||||
b, err := os.ReadFile("/proc/self/cgroup")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if m := cgroupContainerRe.FindString(string(b)); m != "" {
|
||||
return m
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isProtected reports whether the agent refuses to act on this workload.
|
||||
//
|
||||
// The refusal lives here, in the agent, and not in the control plane. As with
|
||||
// the console relay hardcoding 127.0.0.1 agent-side: the control plane may name
|
||||
// a target, but the agent decides what it will do to itself. A server-side
|
||||
// denylist alone would be bypassed by the next dispatch path someone adds.
|
||||
func isProtected(kind, id, name string) bool {
|
||||
if kind == "unit" {
|
||||
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
|
||||
}
|
||||
if ownContainerID == "" {
|
||||
return false
|
||||
}
|
||||
// Container IDs are commonly abbreviated to 12 characters; compare on the
|
||||
// shorter of the two so a short id still matches a full one.
|
||||
return strings.HasPrefix(ownContainerID, id) || strings.HasPrefix(id, ownContainerID)
|
||||
}
|
||||
|
||||
// markProtected stamps the flag onto a collected list so the UI can render the
|
||||
// action disabled with a reason.
|
||||
func markProtected(wls []Workload) {
|
||||
for i := range wls {
|
||||
wls[i].Protected = isProtected(wls[i].Kind, wls[i].ID, wls[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Control starts, stops or restarts a workload.
|
||||
func Control(ctx context.Context, kind, id, action string) error {
|
||||
switch action {
|
||||
case "start", "stop", "restart":
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
|
||||
// Checked before anything else happens, and checked here rather than only
|
||||
// on the server. See isProtected.
|
||||
if isProtected(kind, id, strings.TrimSuffix(id, ".service")) {
|
||||
return fmt.Errorf("%w: %s", ErrProtected, id)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, controlTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", action, id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "systemctl", action, id)
|
||||
default:
|
||||
return fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Workload is one container or one systemd unit, agent-side. It mirrors
|
||||
// models.Workload on the server.
|
||||
type Workload struct {
|
||||
Kind string
|
||||
ID string
|
||||
Name string
|
||||
State string
|
||||
Health string
|
||||
Image string
|
||||
Stack string
|
||||
Ports []string
|
||||
Restarts int
|
||||
StartedAt time.Time
|
||||
Protected bool
|
||||
}
|
||||
|
||||
const dockerTimeout = 30 * time.Second
|
||||
|
||||
// dockerInspect is the subset of `docker inspect` output we read.
|
||||
//
|
||||
// We use inspect rather than `docker ps --format '{{json .}}'` because ps
|
||||
// reports health and uptime inside a human Status string — "Up 2 hours
|
||||
// (healthy)" — and anything built on that is parsing English that is
|
||||
// localised, reworded between releases, and silently different for a paused or
|
||||
// restarting container. inspect gives typed fields instead.
|
||||
type dockerInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
State struct {
|
||||
Status string `json:"Status"`
|
||||
StartedAt string `json:"StartedAt"`
|
||||
Restarting bool `json:"Restarting"`
|
||||
Health *struct {
|
||||
Status string `json:"Status"`
|
||||
} `json:"Health"`
|
||||
} `json:"State"`
|
||||
Config struct {
|
||||
Image string `json:"Image"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
} `json:"Config"`
|
||||
RestartCount int `json:"RestartCount"`
|
||||
NetworkSettings struct {
|
||||
Ports map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
} `json:"Ports"`
|
||||
} `json:"NetworkSettings"`
|
||||
}
|
||||
|
||||
// collectDocker enumerates containers. It returns ok=false with an empty error
|
||||
// string when Docker is simply not installed — the common case on this fleet,
|
||||
// and not a fault.
|
||||
func collectDocker(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return nil, false, "" // not installed; not an error
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, dockerTimeout)
|
||||
defer cancel()
|
||||
|
||||
idsOut, err := exec.CommandContext(ctx, "docker", "ps", "-aq").Output()
|
||||
if err != nil {
|
||||
// Installed but not answering: a different problem with a different
|
||||
// fix, so it carries a message where "not installed" does not.
|
||||
return nil, false, "docker ps failed: " + errText(err)
|
||||
}
|
||||
|
||||
ids := strings.Fields(string(idsOut))
|
||||
if len(ids) == 0 {
|
||||
return []Workload{}, true, "" // Docker present, nothing running
|
||||
}
|
||||
|
||||
args := append([]string{"inspect", "--format", "{{json .}}"}, ids...)
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).Output()
|
||||
if err != nil {
|
||||
return nil, false, "docker inspect failed: " + errText(err)
|
||||
}
|
||||
|
||||
var wls []Workload
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var di dockerInspect
|
||||
if err := json.Unmarshal([]byte(line), &di); err != nil {
|
||||
continue
|
||||
}
|
||||
wls = append(wls, dockerToWorkload(di))
|
||||
}
|
||||
return wls, true, ""
|
||||
}
|
||||
|
||||
func dockerToWorkload(di dockerInspect) Workload {
|
||||
w := Workload{
|
||||
Kind: "container",
|
||||
ID: di.ID,
|
||||
Name: strings.TrimPrefix(di.Name, "/"),
|
||||
State: di.State.Status,
|
||||
Image: di.Config.Image,
|
||||
Restarts: di.RestartCount,
|
||||
}
|
||||
if di.State.Health != nil {
|
||||
w.Health = strings.ToLower(di.State.Health.Status)
|
||||
}
|
||||
// The compose project label is what Docker itself treats as authoritative.
|
||||
// No YAML is read from disk: a compose file there may not be what is running.
|
||||
if v := di.Config.Labels["com.docker.compose.project"]; v != "" {
|
||||
w.Stack = v
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, di.State.StartedAt); err == nil {
|
||||
w.StartedAt = t
|
||||
}
|
||||
for container, bindings := range di.NetworkSettings.Ports {
|
||||
for _, b := range bindings {
|
||||
w.Ports = append(w.Ports, b.HostIP+":"+b.HostPort+"->"+container)
|
||||
}
|
||||
}
|
||||
// Map iteration order is random; sort so a stored snapshot does not reorder
|
||||
// its own ports between two otherwise identical reports.
|
||||
sort.Strings(w.Ports)
|
||||
return w
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return strings.TrimSpace(string(ee.Stderr))
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxLogLines and MaxLogBytes are BOTH enforced, whichever binds first.
|
||||
//
|
||||
// A line count alone does not bound size: 500 lines of a container printing
|
||||
// 4KB JSON blobs is 2MB travelling over the bus. This is the same reasoning
|
||||
// that gave workflow logs a per-line cap as well as a per-run one.
|
||||
MaxLogLines = 500
|
||||
MaxLogBytes = 256 * 1024
|
||||
|
||||
logTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
// Logs returns a bounded snapshot of a workload's recent output.
|
||||
//
|
||||
// There is no follow mode. The browser console already offers a real terminal
|
||||
// on the same server where `docker logs -f` works properly, with its own
|
||||
// scrollback and cancellation. A snapshot answers "why did this restart",
|
||||
// which is the question that sends people to the console in the first place.
|
||||
func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error) {
|
||||
if tail <= 0 || tail > MaxLogLines {
|
||||
tail = MaxLogLines
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, logTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", "logs",
|
||||
"--tail", strconv.Itoa(tail), "--timestamps", id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
|
||||
"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
|
||||
default:
|
||||
return "", false, fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
// docker logs writes container stderr to our stderr, so both streams must
|
||||
// be captured or half the output silently disappears.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return "", false, fmt.Errorf("read logs for %s: %s", id, errText(err))
|
||||
}
|
||||
|
||||
text, truncated := capLog(string(out))
|
||||
return text, truncated, nil
|
||||
}
|
||||
|
||||
// capLog enforces both limits, trimming from the FRONT: the most recent lines
|
||||
// are the ones worth keeping.
|
||||
func capLog(s string) (string, bool) {
|
||||
truncated := false
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > MaxLogLines {
|
||||
lines = lines[len(lines)-MaxLogLines:]
|
||||
truncated = true
|
||||
}
|
||||
s = strings.Join(lines, "\n")
|
||||
|
||||
if len(s) > MaxLogBytes {
|
||||
s = s[len(s)-MaxLogBytes:]
|
||||
// Drop the leading partial line left by a byte-wise cut.
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
truncated = true
|
||||
}
|
||||
|
||||
return s, truncated
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const systemdTimeout = 30 * time.Second
|
||||
|
||||
// excludedPrefixes drops the platform's own units. A typical host carries 300+
|
||||
// units and systemd accounts for most of them; listing all of them buries the
|
||||
// ten anyone cares about.
|
||||
var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"}
|
||||
|
||||
// collectSystemd enumerates services in two passes, because "running or
|
||||
// failed" and "enabled but stopped" are different questions — and an enabled
|
||||
// unit that is not running is exactly the one worth seeing.
|
||||
func collectSystemd(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, systemdTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Column output rather than --output=json: the JSON flag needs systemd
|
||||
// 246+, and this fleet includes older stable distributions. The columns
|
||||
// have been stable considerably longer than the JSON has existed.
|
||||
unitsOut, err := exec.CommandContext(ctx, "systemctl",
|
||||
"list-units", "--type=service", "--state=running,failed",
|
||||
"--no-legend", "--plain", "--no-pager").Output()
|
||||
if err != nil {
|
||||
return nil, false, "systemctl list-units failed: " + errText(err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var wls []Workload
|
||||
|
||||
for _, line := range strings.Split(string(unitsOut), "\n") {
|
||||
f := strings.Fields(line)
|
||||
// UNIT LOAD ACTIVE SUB DESCRIPTION…
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
name := f[0]
|
||||
if excluded(name) || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: name,
|
||||
Name: strings.TrimSuffix(name, ".service"),
|
||||
State: f[2], // ACTIVE: active | failed | activating | inactive
|
||||
})
|
||||
}
|
||||
|
||||
filesOut, err := exec.CommandContext(ctx, "systemctl",
|
||||
"list-unit-files", "--type=service", "--state=enabled",
|
||||
"--no-legend", "--plain", "--no-pager").Output()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(filesOut), "\n") {
|
||||
f := strings.Fields(line)
|
||||
// UNIT FILE STATE [PRESET]
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
name := f[0]
|
||||
if excluded(name) || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: name,
|
||||
Name: strings.TrimSuffix(name, ".service"),
|
||||
State: "inactive", // enabled but not currently running
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return wls, true, ""
|
||||
}
|
||||
|
||||
func excluded(name string) bool {
|
||||
for _, p := range excludedPrefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Result is one collection pass.
|
||||
type Result struct {
|
||||
Workloads []Workload
|
||||
DockerOK bool
|
||||
DockerError string
|
||||
SystemdOK bool
|
||||
SystemdError string
|
||||
}
|
||||
|
||||
// Collect enumerates every workload on this host. Linux only.
|
||||
func Collect(ctx context.Context) Result {
|
||||
if runtime.GOOS != "linux" {
|
||||
return Result{}
|
||||
}
|
||||
|
||||
var r Result
|
||||
containers, dockerOK, dockerErr := collectDocker(ctx)
|
||||
units, systemdOK, systemdErr := collectSystemd(ctx)
|
||||
|
||||
r.DockerOK, r.DockerError = dockerOK, dockerErr
|
||||
r.SystemdOK, r.SystemdError = systemdOK, systemdErr
|
||||
r.Workloads = append(append([]Workload{}, containers...), units...)
|
||||
|
||||
markProtected(r.Workloads)
|
||||
return r
|
||||
}
|
||||
|
||||
// Hash fingerprints a workload set so an unchanged set never has to be sent.
|
||||
//
|
||||
// It sorts first: `docker ps` output ordering is not stable, and an
|
||||
// ordering-sensitive hash would resend the full list every 60 seconds forever
|
||||
// — a cost visible only as traffic.
|
||||
//
|
||||
// StartedAt is deliberately excluded: it does not change while a container
|
||||
// runs, and including it would add nothing. Restarts IS included, because a
|
||||
// container cycling is exactly the change worth reporting.
|
||||
func Hash(wls []Workload) string {
|
||||
lines := make([]string, 0, len(wls))
|
||||
for _, w := range wls {
|
||||
lines = append(lines, strings.Join([]string{
|
||||
w.Kind, w.ID, w.Name, w.State, w.Health, w.Image, w.Stack,
|
||||
strconv.Itoa(w.Restarts),
|
||||
}, "\x00"))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
h := sha256.New()
|
||||
for _, l := range lines {
|
||||
h.Write([]byte(l))
|
||||
h.Write([]byte("\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: vantage
|
||||
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
|
||||
type: application
|
||||
version: 1.0.7
|
||||
appVersion: "1.0.7"
|
||||
version: 1.0.8
|
||||
appVersion: "1.0.8"
|
||||
|
||||
@@ -92,8 +92,8 @@ ingress:
|
||||
api:
|
||||
enabled: false
|
||||
paths:
|
||||
- /api
|
||||
- /auth
|
||||
- /api/
|
||||
- /auth/
|
||||
- /update
|
||||
- /install
|
||||
- /update.ps1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,938 @@
|
||||
# Instance Rename in Vantage HQ — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let an HQ customer (owner or admin) rename a cloud instance, which re-derives its slug and moves it to a new DNS host, with staff able to do the same without the cooldown.
|
||||
|
||||
**Architecture:** Slug derivation stays in `shared/provision`, beside the create path that already owns it. Admin reaches the control plane only through `cloudprov`, writing `instances` — a collection it already writes. Admin's own row (`admin_instances`) is updated second and carries the 24h cooldown timestamp, because the cooldown is admin's policy and the control plane has no opinion about it. The portal shows the new host and asks the customer to click through; it does not redirect.
|
||||
|
||||
**Note:** This repo has no automated test suite and the user has ruled out adding test files. Every task verifies by build, vet and (Task 8) manual exercise.
|
||||
|
||||
**Tech Stack:** Go 1.x (gin, mongo-driver v2), Next.js 16 App Router + TanStack Query + Tailwind 3 (`adminsite`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-12-instance-rename-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- A licence binds an instance **UUID**, not a slug. A rename must not issue a licence, call Paddle, or touch `licenses`, `subscriptions` or `entitlements`.
|
||||
- Admin's control-plane write boundary is unchanged: `cloudprov` writes `instances` and `users` only. Do not add a write to any other control-plane collection.
|
||||
- Customer rename is **cloud only**. Self-hosted is refused with the existing `selfHostedRefusal` constant and HTTP **400**, matching `members.go`.
|
||||
- Cooldown for customers is **24 hours**, tracked by `admin_instances.renamed_at`. Staff bypass it and must **not** write `renamed_at`.
|
||||
- No `-2` suffix loop on rename. A taken slug is a refusal (`ErrSlugTaken` → HTTP 409).
|
||||
- No component in `adminsite` may carry a hex colour; use the existing token classes (`text-ink-2`, `text-ink-3`, `border-rule`, `text-accent`, `text-expired`, `bg-panel-2`).
|
||||
- The host domain used for display is `vantage.hostxtra.co.uk`, already hardcoded in `adminsite/components/InstanceRecord.tsx` and the customer instance page.
|
||||
- Commit messages follow the repo's existing style: `feat: Sentence case summary` / `fix: …` / `docs: …`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Slug derivation and the control-plane rename
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/provision/instance.go`
|
||||
**Interfaces:**
|
||||
- Consumes: `BaseSlug(name string) (string, error)`, `ErrNameRejected` — both already in `shared/provision`.
|
||||
- Produces:
|
||||
- `provision.ErrSlugTaken` (`error`)
|
||||
- `provision.RenameSlug(name, currentSlug string) (string, error)`
|
||||
- `provision.RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)`
|
||||
- `provision.RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error`
|
||||
|
||||
Behaviour `RenameSlug` must have, verified by reading rather than by test (this
|
||||
repo has no Go test suite and the user has ruled out adding one):
|
||||
|
||||
| Input name | Current slug | Result |
|
||||
|---|---|---|
|
||||
| `Acme Ltd` | `acme` | `acme-ltd` |
|
||||
| `ACME!` | `acme` | `acme` — still derives to the current slug, so not a move |
|
||||
| `Acme` | `acme-2` | `acme` — a creation-time collision suffix derives from no name, so moving off it is a real move |
|
||||
| `ab` | any | `ErrNameRejected` |
|
||||
| `Admin` | any | `ErrNameRejected` (reserved) |
|
||||
| `!!!` | any | `ErrNameRejected` |
|
||||
| 50 `a`s | any | truncated to `MaxSlugLength`, exactly as `BaseSlug` truncates on create |
|
||||
|
||||
- [ ] **Step 1: Write the implementation**
|
||||
|
||||
Append to `shared/provision/instance.go`:
|
||||
|
||||
```go
|
||||
// ErrSlugTaken means the slug a new name derives to already belongs to another
|
||||
// instance.
|
||||
//
|
||||
// Rename refuses rather than appending a counter the way creation does. Creation
|
||||
// appends because the customer is waiting on an instance and any free slug will
|
||||
// do; a rename is a request for one specific host, and silently landing them on
|
||||
// "acme-2" answers a question they did not ask.
|
||||
var ErrSlugTaken = errors.New("slug taken")
|
||||
|
||||
// RenameSlug derives the slug a rename to name would move an instance to, given
|
||||
// the slug it holds now.
|
||||
//
|
||||
// It returns the current slug unchanged when the name still derives to it, so a
|
||||
// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move
|
||||
// and cannot collide with the instance's own slug.
|
||||
func RenameSlug(name, currentSlug string) (string, error) {
|
||||
base, err := BaseSlug(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
}
|
||||
if base == currentSlug {
|
||||
return currentSlug, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// RenameInstance changes an instance's name and re-derives its slug from it.
|
||||
//
|
||||
// The count-then-update is racy on its own, and is safe for the same reason
|
||||
// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a
|
||||
// lost race surfaces as a duplicate-key error. Unlike creation there is nothing
|
||||
// to retry with — the caller asked for one specific name — so it becomes
|
||||
// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the
|
||||
// index.
|
||||
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) {
|
||||
var inst models.Instance
|
||||
if err := db.Collection("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slug, err := RenameSlug(name, inst.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if slug != inst.Slug {
|
||||
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{
|
||||
"slug": slug,
|
||||
"instance_id": bson.M{"$ne": instanceID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Collection("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inst.Name = name
|
||||
inst.Slug = slug
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename
|
||||
// whose caller-side bookkeeping then failed.
|
||||
//
|
||||
// It derives nothing. The values being restored may include a creation-time
|
||||
// collision suffix that no name derives to, so re-running RenameInstance with the
|
||||
// old name would not reproduce them.
|
||||
func RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error {
|
||||
_, err := db.Collection("instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID},
|
||||
bson.M{"$set": bson.M{"name": name, "slug": slug}})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and vet**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./shared/... && go vet ./shared/provision/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add shared/provision/instance.go
|
||||
git commit -m "feat: Add instance rename to shared provisioning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Admin's row and the cloudprov wrappers
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/models/models.go` (the `Instance` struct, ~line 129; constants block near `RenewWindow`, ~line 105)
|
||||
- Modify: `admin/internal/cloudprov/cloudprov.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `provision.RenameInstance`, `provision.RestoreInstanceIdentity` (Task 1).
|
||||
- Produces:
|
||||
- `models.RenameCooldown` (`time.Duration`)
|
||||
- `models.Instance.RenamedAt *time.Time` (bson `renamed_at`, json `renamed_at`)
|
||||
- `cloudprov.RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)`
|
||||
- `cloudprov.RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error`
|
||||
|
||||
- [ ] **Step 1: Add the cooldown constant**
|
||||
|
||||
In `admin/internal/models/models.go`, directly beneath the `RenewWindow` block:
|
||||
|
||||
```go
|
||||
// RenameCooldown is how long a customer must wait between renames of one
|
||||
// instance.
|
||||
//
|
||||
// A rename moves the instance's DNS host and invalidates every saved link to it,
|
||||
// so this exists to make that a considered act rather than a slider. Staff are
|
||||
// not subject to it: a support conversation about a name is already a human
|
||||
// deciding.
|
||||
const RenameCooldown = 24 * time.Hour
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the field to `Instance`**
|
||||
|
||||
In the same file, inside the `Instance` struct, after `RelinkCount`:
|
||||
|
||||
```go
|
||||
// RenamedAt is when this instance last changed name, and backs the customer
|
||||
// rename cooldown. It is a pointer because absent means "never renamed"; a
|
||||
// zero time.Time would read as year 1 — an inert cooldown, but only by
|
||||
// accident. Staff renames deliberately leave it alone.
|
||||
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the cloudprov wrappers**
|
||||
|
||||
Append to `admin/internal/cloudprov/cloudprov.go`:
|
||||
|
||||
```go
|
||||
// RenameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// It writes `instances` and nothing else, so admin's control-plane write
|
||||
// boundary is unchanged. It issues no licence: a licence binds the instance
|
||||
// UUID, which a rename never touches.
|
||||
func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error) {
|
||||
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
|
||||
// caller unwinding a rename whose admin-side write failed. Leaving the two
|
||||
// databases disagreeing would have HQ print a host that is not the host.
|
||||
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
|
||||
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... ./shared/...`
|
||||
Expected: clean build, no output.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/models/models.go admin/internal/cloudprov/cloudprov.go
|
||||
git commit -m "feat: Add rename cooldown field and cloudprov rename"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Customer rename endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/api/customer.go` (add handler; `loginURLFor` at ~line 442 is already in this file)
|
||||
- Modify: `admin/internal/api/routes.go` (~line 77, beside the other `/instances/:id/*` customer routes)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ownedInstance(c, id) (*models.Instance, bool)`, `selfHostedRefusal` (`members.go`), `loginURLFor(slug) string`, `cloudprov.RenameInstance`, `cloudprov.RestoreInstanceIdentity`, `models.RenameCooldown`, `provision.ErrSlugTaken`, `provision.ErrNameRejected`.
|
||||
- Produces: `PUT /api/instances/:id/name` returning `{instance_id, name, slug, login_url}`.
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
Append to `admin/internal/api/customer.go`:
|
||||
|
||||
```go
|
||||
// renameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// The control plane is written FIRST, because instances.slug carries the unique
|
||||
// index and that index is what actually settles a race between two accounts
|
||||
// reaching for the same name. Admin's own row follows; if that write fails the
|
||||
// control plane is put back, because HQ printing a host that is not the host is
|
||||
// worse than a failed rename.
|
||||
//
|
||||
// No licence is issued and Paddle is not called: a licence binds the instance
|
||||
// UUID, and a rename does not change it.
|
||||
func renameInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
if inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if inst.RenamedAt != nil {
|
||||
if until := inst.RenamedAt.Add(models.RenameCooldown); time.Now().UTC().Before(until) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
|
||||
"retry_after": until,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
"renamed_at": time.Now().UTC(),
|
||||
}}); err != nil {
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
|
||||
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: inst.Slug + " -> " + renamed.Slug, IP: c.ClientIP()})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance_id": inst.InstanceID,
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
// The same builder the licence emails use, rather than a second opinion
|
||||
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
|
||||
"login_url": loginURLFor(renamed.Slug),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Check the imports**
|
||||
|
||||
`customer.go` must import `errors`, `fmt`, `log`, `net/http`, `strings`, `time`, `audit`, `auth`, `cloudprov`, `db`, `models`, `license`, `provision`, `gin`, `bson`. Most are already there — add only what the compiler asks for. `provision` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision`; `license` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`.
|
||||
|
||||
- [ ] **Step 3: Mount the route**
|
||||
|
||||
In `admin/internal/api/routes.go`, in the `cust` group beside the other instance routes (after `cust.POST("/instances/:id/claim-free", …)`):
|
||||
|
||||
```go
|
||||
// Renaming moves the instance's DNS host, so it is owner-or-admin like
|
||||
// every other instance mutation. Cloud only; the handler refuses the rest.
|
||||
cust.PUT("/instances/:id/name",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
renameInstance)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/api/customer.go admin/internal/api/routes.go
|
||||
git commit -m "feat: Add customer instance rename endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Staff rename endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `admin/internal/api/staff.go`
|
||||
- Modify: `admin/internal/api/routes.go` (the `staff` group, beside `staff.POST("/instances/:id/relink", …)`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything Task 3 consumes, plus `db.Admin`.
|
||||
- Produces: `PUT /api/staff/instances/:id/name` returning `{instance_id, name, slug}`.
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
Append to `admin/internal/api/staff.go`:
|
||||
|
||||
```go
|
||||
// staffRenameInstance renames any instance, with no cooldown.
|
||||
//
|
||||
// It does NOT write renamed_at: a staff rename must not start the customer's
|
||||
// 24h clock, or fixing a name for someone locks them out of fixing it further.
|
||||
//
|
||||
// On self-hosted it changes admin's label only. There is no control-plane row to
|
||||
// write — the install is the customer's — and no slug, because self-hosted has
|
||||
// no tenant subdomain.
|
||||
func staffRenameInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
set := bson.M{"name": name}
|
||||
slug := inst.Slug
|
||||
|
||||
if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
|
||||
renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
slug = renamed.Slug
|
||||
set["slug"] = renamed.Slug
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
|
||||
if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
|
||||
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
|
||||
Target: inst.InstanceID, Detail: inst.Slug + " -> " + slug, IP: c.ClientIP()})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
|
||||
}
|
||||
```
|
||||
|
||||
`staff.go` will need `errors`, `log`, `cloudprov` and `provision` added to its imports; `fmt`, `net/http`, `strings`, `time`, `audit`, `auth`, `db`, `models`, `license`, `bson` are already there.
|
||||
|
||||
- [ ] **Step 2: Mount the route**
|
||||
|
||||
In `routes.go`, in the `staff` group after `staff.POST("/instances/:id/relink", staffRelink)`:
|
||||
|
||||
```go
|
||||
staff.PUT("/instances/:id/name", staffRenameInstance)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add admin/internal/api/staff.go admin/internal/api/routes.go
|
||||
git commit -m "feat: Add staff instance rename endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `adminsite` API client and slug preview
|
||||
|
||||
**Files:**
|
||||
- Create: `adminsite/lib/slug.ts`
|
||||
- Modify: `adminsite/lib/api.ts` (the `Instance` interface ~line 123; the `api` object's instance calls ~line 305; `api.staff` ~line 360)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PUT /api/instances/:id/name`, `PUT /api/staff/instances/:id/name` (Tasks 3 and 4).
|
||||
- Produces:
|
||||
- `INSTANCE_DOMAIN`, `slugify(name: string): string`, `slugError(name: string): string | undefined` from `@/lib/slug`
|
||||
- `RenameResult` interface, `api.renameInstance(id, name): Promise<RenameResult>`, `api.staff.renameInstance(id, name): Promise<RenameResult>`
|
||||
- `Instance.renamed_at?: string`
|
||||
|
||||
- [ ] **Step 1: Create the slug mirror**
|
||||
|
||||
Create `adminsite/lib/slug.ts`:
|
||||
|
||||
```ts
|
||||
/*
|
||||
* A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the
|
||||
* host a rename would move an instance to while the customer types.
|
||||
*
|
||||
* It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it
|
||||
* must change in the same commit as the Go one — the same hazard as
|
||||
* web/lib/targets.ts. The preview is a courtesy; the server's 409 is the
|
||||
* boundary, and the two are allowed to disagree without anything breaking.
|
||||
*/
|
||||
|
||||
/** Mirrors provision.MinSlugLength / MaxSlugLength. */
|
||||
export const MIN_SLUG_LENGTH = 3;
|
||||
export const MAX_SLUG_LENGTH = 40;
|
||||
|
||||
/** Mirrors provision.ReservedSlugs. */
|
||||
const RESERVED = new Set([
|
||||
"www", "api", "app", "admin", "auth",
|
||||
"install", "static", "_next", "default",
|
||||
]);
|
||||
|
||||
/*
|
||||
* The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the
|
||||
* customer instance page; those predate this file and are left alone rather than
|
||||
* refactored under a rename change.
|
||||
*/
|
||||
export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk";
|
||||
|
||||
/** Mirrors provision.Slugify. */
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Mirrors provision.BaseSlug's truncation. */
|
||||
export function baseSlug(name: string): string {
|
||||
return slugify(name).slice(0, MAX_SLUG_LENGTH);
|
||||
}
|
||||
|
||||
/** The reason a name cannot become a slug, or undefined when it can. */
|
||||
export function slugError(name: string): string | undefined {
|
||||
const base = slugify(name);
|
||||
if (base.length < MIN_SLUG_LENGTH) {
|
||||
return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`;
|
||||
}
|
||||
if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) {
|
||||
return "That name is reserved.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The host an instance on this slug is reached at. */
|
||||
export function hostFor(slug: string): string {
|
||||
return `${slug}.${INSTANCE_DOMAIN}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the API client**
|
||||
|
||||
In `adminsite/lib/api.ts`, add `renamed_at` to `Instance` (after `relink_count`):
|
||||
|
||||
```ts
|
||||
renamed_at?: string;
|
||||
```
|
||||
|
||||
Add the response type beside the other interfaces:
|
||||
|
||||
```ts
|
||||
export interface RenameResult {
|
||||
instance_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
/** Empty when APP_LOGIN_URL is unset on the server. */
|
||||
login_url?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Add the call to the `api` object, after `renewInstance`:
|
||||
|
||||
```ts
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/instances/${id}/name`, { name }),
|
||||
```
|
||||
|
||||
And to `api.staff`, after `relink`:
|
||||
|
||||
```ts
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npx tsc --noEmit`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add adminsite/lib/slug.ts adminsite/lib/api.ts
|
||||
git commit -m "feat: Add rename calls and slug preview to the HQ client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: The rename panel and the customer instance page
|
||||
|
||||
**Files:**
|
||||
- Create: `adminsite/components/RenamePanel.tsx`
|
||||
- Modify: `adminsite/app/(customer)/instances/[id]/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.renameInstance` / `api.staff.renameInstance`, `RenameResult` (Task 5); `slugError`, `baseSlug`, `hostFor` (Task 5); `Panel`, `Note` (`@/components/Panel`), `Button` (`@/components/Button`), `Field` (`@/components/Field`), `ApiError` (`@/lib/api`).
|
||||
- Produces: `RenamePanel({ currentName, currentSlug, onRename })` — a default-collapsed control; `onRename` is `(name: string) => Promise<RenameResult>`.
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Create `adminsite/components/RenamePanel.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Field } from "./Field";
|
||||
import { Note } from "./Panel";
|
||||
import { ApiError, type RenameResult } from "@/lib/api";
|
||||
import { baseSlug, hostFor, slugError } from "@/lib/slug";
|
||||
|
||||
/*
|
||||
* The rename control, and only the control — the same shape as RelinkPanel: an
|
||||
* input that expands in place rather than a modal, because this app has no modal
|
||||
* and one action with one field does not need one.
|
||||
*
|
||||
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
|
||||
* disagree with the server; the 409 that comes back is the answer that counts.
|
||||
*/
|
||||
export function RenamePanel({
|
||||
currentName,
|
||||
currentSlug,
|
||||
onRename,
|
||||
}: {
|
||||
currentName: string;
|
||||
currentSlug: string;
|
||||
onRename: (name: string) => Promise<RenameResult>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(currentName);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<RenameResult | undefined>();
|
||||
|
||||
const name = value.trim();
|
||||
const derived = baseSlug(name);
|
||||
const invalid = slugError(name);
|
||||
// A cosmetic edit that lands on the same slug is still a rename worth doing —
|
||||
// the name is what the customer reads. Only an empty or unchanged name is
|
||||
// nothing to submit.
|
||||
const unchanged = name === currentName.trim();
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await onRename(name);
|
||||
setDone(res);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
const host = done.login_url || `https://${hostFor(done.slug)}`;
|
||||
return (
|
||||
<Note tone="warn">
|
||||
<span className="grid gap-2">
|
||||
<span>
|
||||
This instance is now <strong>{done.name}</strong>, at{" "}
|
||||
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and your
|
||||
sign-in does not follow it — you will need to sign in again there.
|
||||
</span>
|
||||
<a href={host} className="justify-self-start font-mono text-[0.78rem] text-accent underline">
|
||||
Open {hostFor(done.slug)} →
|
||||
</a>
|
||||
</span>
|
||||
</Note>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{open && (
|
||||
<Field
|
||||
label="Instance name"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error ?? (name ? invalid : undefined)}
|
||||
hint={
|
||||
name && !invalid ? (
|
||||
<>
|
||||
Moves to <span className="font-mono">{hostFor(derived)}</span>
|
||||
{derived === currentSlug && " — the address does not change"}
|
||||
</>
|
||||
) : (
|
||||
"Letters and digits; everything else becomes a hyphen."
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
|
||||
onClick={() => (open ? submit() : setOpen(true))}
|
||||
>
|
||||
{busy ? "Renaming…" : "Rename instance"}
|
||||
</Button>
|
||||
{open && (
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`Note` is `({ tone = "accent" | "warn" | "expired", children })` and renders a `<p>`, which is why the success state wraps its two lines in a `<span className="grid gap-2">` rather than block elements.
|
||||
|
||||
- [ ] **Step 2: Mount it on the customer instance page**
|
||||
|
||||
In `adminsite/app/(customer)/instances/[id]/page.tsx`:
|
||||
|
||||
Add the imports:
|
||||
|
||||
```tsx
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```tsx
|
||||
import { useSession } from "@/lib/session";
|
||||
```
|
||||
|
||||
Inside `InstancePage`, with the other hooks (hooks must precede the early returns already in this component):
|
||||
|
||||
```tsx
|
||||
// useSession is the app's one way to ask who the caller is — it shares the
|
||||
// ["me"] query, so this adds no request.
|
||||
const { session } = useSession();
|
||||
```
|
||||
|
||||
and after the `cloud` const:
|
||||
|
||||
```tsx
|
||||
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
|
||||
```
|
||||
|
||||
Then add the panel to `PageFrame`'s children, directly after the `MembersPanel` line:
|
||||
|
||||
```tsx
|
||||
{/*
|
||||
* Address rather than "Rename": the panel is about where this
|
||||
* instance lives, and the rename is how you change it. Cloud
|
||||
* only — a self-hosted install has no tenant subdomain for us to
|
||||
* move.
|
||||
*/}
|
||||
{cloud && mayRename && (
|
||||
<Panel title="Address" meta={host ?? undefined}>
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
|
||||
one, so saved links and bookmarks to it stop working.
|
||||
</p>
|
||||
<RenamePanel
|
||||
currentName={instance.name}
|
||||
currentSlug={instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.renameInstance(instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add adminsite/components/RenamePanel.tsx "adminsite/app/(customer)/instances/[id]/page.tsx"
|
||||
git commit -m "feat: Let a customer rename a cloud instance from HQ"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Staff instance page rename
|
||||
|
||||
**Files:**
|
||||
- Modify: `adminsite/app/(staff)/staff/instances/[id]/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RenamePanel` (Task 6), `api.staff.renameInstance` (Task 5).
|
||||
- Produces: nothing later tasks depend on.
|
||||
|
||||
- [ ] **Step 1: Add the panel**
|
||||
|
||||
In `adminsite/app/(staff)/staff/instances/[id]/page.tsx`, add the imports:
|
||||
|
||||
```tsx
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
```
|
||||
|
||||
and, inside `StaffInstancePage`, add `const qc = useQueryClient();` at the top of the component if it is not already there (`useQueryClient` is already imported for `EntitlementSection`).
|
||||
|
||||
Add this panel after the "Licence history" panel:
|
||||
|
||||
```tsx
|
||||
{/*
|
||||
* Staff rename has no cooldown and does not start the customer's:
|
||||
* fixing a name on someone's behalf must not spend their next 24
|
||||
* hours.
|
||||
*/}
|
||||
<Panel title="Name" meta={data.instance.deployment === "cloud" ? "Moves the address" : "Label only"}>
|
||||
<RenamePanel
|
||||
currentName={data.instance.name}
|
||||
currentSlug={data.instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.staff.renameInstance(data.instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `cd /go-projects/vantage/adminsite && npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add "adminsite/app/(staff)/staff/instances/[id]/page.tsx"
|
||||
git commit -m "feat: Let staff rename an instance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Documentation and end-to-end verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (the Admin REST API route list, and the `admin_instances` note under MongoDB Collections)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything above.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Update the Admin REST API route list**
|
||||
|
||||
In `CLAUDE.md`, in the customer-session block, after the `POST /instances/:id/claim-free` line:
|
||||
|
||||
```
|
||||
PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown)
|
||||
```
|
||||
|
||||
and in the staff-session block, after `POST /instances/:id/issue · /instances/:id/relink`:
|
||||
|
||||
```
|
||||
PUT /instances/:id/name # rename any instance, no cooldown
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the design note**
|
||||
|
||||
In `CLAUDE.md`, under "Grants project, they do not federate" (admin's control-plane write boundary is described nearby), add a short paragraph:
|
||||
|
||||
```markdown
|
||||
**A rename moves the host, and the licence does not care.** `PUT
|
||||
/api/instances/:id/name` re-derives the slug from the new name through
|
||||
`provision.RenameSlug` — the same rules that named the instance at creation —
|
||||
and writes the control plane first, because `instances.slug`'s unique index is
|
||||
what settles a race between two accounts reaching for one name. A taken slug is
|
||||
a refusal, not an `acme-2`: creation appends a counter because any free slug
|
||||
will do, and a rename is a request for one specific host. A licence binds the
|
||||
instance UUID, so nothing is reissued and Paddle is not called. The old host
|
||||
keeps resolving for up to 60s (`instancehost.go`'s cache, which admin cannot
|
||||
reach into), and `km_session` is host-only, so the customer signs in again on
|
||||
the new address — the portal says so rather than redirecting them into a login
|
||||
screen with no explanation. The 24h cooldown lives on `admin_instances.renamed_at`
|
||||
because it is admin's policy; staff bypass it and must not write the field.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Full build**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /go-projects/vantage && go build ./... && go vet ./admin/... ./shared/... && (cd adminsite && npm run build)
|
||||
```
|
||||
Expected: all clean.
|
||||
|
||||
- [ ] **Step 4: Manual verification against a running stack**
|
||||
|
||||
Work through each and record the result:
|
||||
|
||||
1. Rename a cloud instance from `/instances/<id>` as an owner. Panel reports the new host.
|
||||
2. In Mongo: `db.instances.findOne({instance_id})` and `db.admin_instances.findOne({instance_id})` agree on `name` and `slug`; `admin_instances.renamed_at` is set.
|
||||
3. The new host serves a login page. The old host stops resolving to the instance within ~60 seconds.
|
||||
4. A second rename inside 24 hours answers `429` with the unlock time.
|
||||
5. Renaming onto a slug another instance holds answers `409` and changes neither database.
|
||||
6. `PUT /api/instances/:id/name` on a self-hosted instance answers `400` with the `selfHostedRefusal` message.
|
||||
7. `GET /api/staff/audit` shows `instance.renamed` with `old-slug -> new-slug`.
|
||||
8. Staff rename of the same instance succeeds immediately and leaves `renamed_at` unchanged.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: Document instance rename in HQ"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Refresh the knowledge graph**
|
||||
|
||||
```bash
|
||||
graphify update .
|
||||
```
|
||||
@@ -0,0 +1,538 @@
|
||||
# Package inventory and CVE findings
|
||||
|
||||
Date: 2026-08-06
|
||||
|
||||
Agents report the packages installed on each server. The control plane matches
|
||||
them against distro security feeds and raises findings that link straight to
|
||||
the patching path that already exists. A finding nobody can fix today can be
|
||||
accepted with a reason and an expiry date rather than sitting red forever.
|
||||
|
||||
This is one of four sub-projects sketched together and deliberately separated:
|
||||
|
||||
| # | Sub-project | Depends on |
|
||||
| - | ----------- | ---------- |
|
||||
| A | **Package inventory + CVE findings** — this spec | nothing |
|
||||
| B | Container/service registry | nothing |
|
||||
| C | Container image scanning | A and B |
|
||||
| D | Compliance profiles (baseline assertions) | shares A's findings UI only |
|
||||
|
||||
A and B are independent of one another. C is the joiner and must not be
|
||||
designed before both exist. D shares a page with A and nothing else — a
|
||||
different collector, a different evaluation model and a different remediation
|
||||
story — so folding it in here would double the size for no shared machinery.
|
||||
|
||||
Scope of this spec is **A, Linux only.** Windows needs a separate source
|
||||
(MSRC CVRF), a separate collector (`Get-HotFix` plus registry) and a KB
|
||||
supersedence matcher that shares no code with the Linux path. That matches the
|
||||
existing position that Windows agents are second-class by design, and the six
|
||||
package managers `updates.go` already detects cover the whole Linux surface.
|
||||
|
||||
---
|
||||
|
||||
## The trap this design is built around
|
||||
|
||||
Distributions **backport** security fixes without changing the upstream
|
||||
version. Ubuntu ships `openssl 3.0.2-0ubuntu1.15` patched against
|
||||
CVE-2023-0286; NVD says version 3.0.2 is vulnerable. Matching installed
|
||||
versions against NVD or CPE ranges therefore reports a fleet full of criticals
|
||||
that are all already fixed.
|
||||
|
||||
That is not merely noisy. It is fatal to the feature: once the first report is
|
||||
mostly wrong, nobody reads the second one, and a genuine finding is lost in the
|
||||
noise it created. Everything below follows from refusing to make that mistake.
|
||||
|
||||
The correct source is the **distribution's own security feed**, keyed on the
|
||||
distribution's own version string — Debian and Ubuntu OVAL/USN, Red Hat OVAL
|
||||
v2, Alpine secdb. `trivy-db` is those feeds pre-merged into one BoltDB
|
||||
artifact, rebuilt every six hours and published as an OCI artifact.
|
||||
|
||||
---
|
||||
|
||||
## Where the vulnerability data comes from
|
||||
|
||||
`trivy-db`, pulled server-side from `ghcr.io/aquasecurity/trivy-db:2`.
|
||||
|
||||
The alternative considered was querying OSV.dev per scan, which needs no
|
||||
storage and no puller. It was rejected on two counts: it requires outbound
|
||||
internet on every scan, which breaks air-gapped installs; and it sends the
|
||||
package list of a customer's entire fleet to a third party. The audience most
|
||||
likely to buy vulnerability scanning is the audience least willing to do that.
|
||||
|
||||
The blob is roughly 50MB, read-only, reproducible, and identified by a version
|
||||
number. **It is not stored in Mongo and not written to `/data`** —
|
||||
`server.persistence` defaults to off and nothing writes to `/data` any more.
|
||||
It does not need durable storage: whichever pod needs it pulls it to its own
|
||||
ephemeral temp directory. Nothing shared, nothing to back up, nothing to
|
||||
migrate.
|
||||
|
||||
`VANTAGE_TRIVY_DB_REF` overrides the default reference so a customer can mirror
|
||||
the artifact into their own registry. It also covers the anonymous ghcr rate
|
||||
limit, which the six-hourly pull cadence already makes unlikely to bite.
|
||||
|
||||
---
|
||||
|
||||
## Only the leader matches
|
||||
|
||||
This is the crux, and it falls out of the replica model already in the
|
||||
codebase.
|
||||
|
||||
Two things trigger matching, and they happen on different pods:
|
||||
|
||||
1. a fleet-wide rescan when `trivy-db` updates — naturally the leader's job
|
||||
2. a server's package list changing — handled by whichever pod holds *that
|
||||
agent's* command stream
|
||||
|
||||
If (2) matched inline, **every replica would need the 50MB database resident**,
|
||||
and a database refresh would have N pods racing to rescan the same fleet and N
|
||||
digests reaching the customer. That is the exact failure `RunAsLeader` exists
|
||||
to prevent, and it is the same argument that put `monitorsched` behind the
|
||||
lock.
|
||||
|
||||
So `ReportPackages` does not match. It upserts the package list and sets
|
||||
`scan_pending: true`. That is all it does.
|
||||
|
||||
`server/internal/vulnsched` then runs inside the **existing**
|
||||
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched`,
|
||||
`workflowsched` and the sweepers — one role, one lock. Every 60 seconds it:
|
||||
|
||||
1. pulls `trivy-db` if the local copy is older than six hours
|
||||
2. if the pulled version differs from `vulndb_meta.db_version`, marks **every**
|
||||
server `scan_pending`
|
||||
3. matches all `scan_pending` servers, clears the flag, diffs against existing
|
||||
findings
|
||||
4. emits **one** digest per tick covering everything newly opened
|
||||
|
||||
Step 4 is why batching is structural rather than bolted on. A `trivy-db`
|
||||
refresh can open several hundred findings across a fleet at once; one message
|
||||
per finding would rate-limit the webhook or get the channel muted, and either
|
||||
way the customer stops receiving the alerts they are paying for. The tick is
|
||||
already the natural batch boundary, so **the failure cannot occur by
|
||||
construction** rather than by a debounce someone has to maintain.
|
||||
|
||||
`scan_pending` lives on the document rather than in memory, for the same reason
|
||||
`next_run_at` and `workflow_log_seq` do: a leader handover between marking and
|
||||
scanning would otherwise lose it. A handover costs the new leader one re-pull
|
||||
of the database.
|
||||
|
||||
The cost of this indirection is up to 60 seconds between an agent reporting a
|
||||
changed package set and its findings updating. For vulnerability data that is
|
||||
nothing, and it buys a single matching path instead of two.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
```
|
||||
agent/internal/packages/ collect installed packages + /etc/os-release
|
||||
proto/ ReportPackages RPC
|
||||
server/internal/vulndb/ puller, BoltDB access, matcher
|
||||
server/internal/vulnsched/ leader-owned tick: pull, scan, digest
|
||||
server/internal/services/ findings, acceptance, alert rules
|
||||
web/app/(app)/vulnerabilities/ fleet board; plus two server-detail tabs
|
||||
```
|
||||
|
||||
`vulnsched` takes the dependencies it needs — `LogEvent` and the notification
|
||||
dispatch — as a `vulnsched.Deps` injected from `main.go`, following
|
||||
`workflowsched`. The manual rescan endpoint does not call into `vulnsched` at
|
||||
all: it sets `scan_pending` on every server and lets the next tick find them,
|
||||
so there is no path by which `services` imports the scheduler and no cycle to
|
||||
avoid later.
|
||||
|
||||
---
|
||||
|
||||
## The wire path
|
||||
|
||||
A new `ReportPackages` RPC on the agent's existing hourly loop — the same
|
||||
`runUpdateCheck` cadence, reusing `updates.go`'s `detectPM()`.
|
||||
|
||||
```protobuf
|
||||
rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
|
||||
|
||||
message ReportPackagesRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string hash = 3; // sha256 of the sorted list
|
||||
OSRelease os = 4;
|
||||
repeated InstalledPackage packages = 5; // omitted when only offering a hash
|
||||
}
|
||||
|
||||
message ReportPackagesResponse {
|
||||
bool need_full = 1; // hash differs; resend with packages populated
|
||||
}
|
||||
```
|
||||
|
||||
The agent calls once with `packages` empty. `need_full` true means the hash
|
||||
differs from what the server holds, and the agent immediately calls again with
|
||||
the list populated.
|
||||
|
||||
The agent sends a SHA-256 of its sorted package list first. If it matches what
|
||||
the server already holds, the server answers `unchanged` and the ~150KB body is
|
||||
never sent. A machine's package set changes rarely, so almost every hour costs
|
||||
one small message, and the rare changed hour costs one extra round trip.
|
||||
|
||||
Folding the list into the existing 15-minute `InventoryReport` static snapshot
|
||||
was rejected: it would re-send ~150KB per server every 15 minutes regardless of
|
||||
change, roughly 40MB/hour of gRPC traffic on a 100-server fleet to transmit
|
||||
data that is almost always identical.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
Four new collections. Every one carries `instance_id` except `vulndb_meta`,
|
||||
which is explained below.
|
||||
|
||||
### `server_packages` — one document per server, not per package
|
||||
|
||||
```go
|
||||
type ServerPackages struct {
|
||||
ID primitive.ObjectID `bson:"_id"`
|
||||
InstanceID primitive.ObjectID `bson:"instance_id"`
|
||||
ServerID string `bson:"server_id"`
|
||||
OS OSRelease `bson:"os"` // family, version_id, arch
|
||||
Hash string `bson:"hash"` // sha256 of the sorted list
|
||||
Packages []InstalledPackage `bson:"packages"`
|
||||
CollectedAt time.Time `bson:"collected_at"`
|
||||
ScanPending bool `bson:"scan_pending"`
|
||||
ScannedAt time.Time `bson:"scanned_at"`
|
||||
Status string `bson:"status"` // ok | unsupported
|
||||
DBVersion int `bson:"db_version"` // last matched against
|
||||
}
|
||||
|
||||
type InstalledPackage struct {
|
||||
Name string `bson:"name"`
|
||||
Version string `bson:"version"` // distro version string, verbatim
|
||||
Epoch int `bson:"epoch,omitempty"`
|
||||
Arch string `bson:"arch"`
|
||||
SourceName string `bson:"source_name,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
One document rather than two thousand is what makes a report a **single atomic
|
||||
upsert with no delta logic** — the hash already established that something
|
||||
changed, so there is nothing to reconcile field by field. A typical Linux host
|
||||
lands near 150KB, comfortably inside the 16MB document limit.
|
||||
|
||||
Indexes: `{instance_id, server_id}` unique, and a multikey
|
||||
`{instance_id, "packages.name"}` for fleet-wide package search.
|
||||
|
||||
`SourceName` is not decoration. **Debian and Ubuntu advisories are keyed on the
|
||||
source package**: a CVE against `openssl` covers the binaries `libssl3`,
|
||||
`openssl` and `libssl-dev`, so matching on binary name alone misses two of the
|
||||
three.
|
||||
|
||||
`OS.VersionID` selects the feed. Ubuntu 22.04 and 24.04 publish different fixed
|
||||
versions for the same CVE, so a scan without it is guesswork.
|
||||
|
||||
### `vuln_findings` — one document per (server, CVE, package)
|
||||
|
||||
```go
|
||||
type VulnFinding struct {
|
||||
ID primitive.ObjectID `bson:"_id"`
|
||||
InstanceID primitive.ObjectID `bson:"instance_id"`
|
||||
ServerID string `bson:"server_id"`
|
||||
|
||||
CVEID string `bson:"cve_id"`
|
||||
PackageName string `bson:"package_name"`
|
||||
Installed string `bson:"installed_version"`
|
||||
FixedIn string `bson:"fixed_in,omitempty"`
|
||||
Severity string `bson:"severity"`
|
||||
CVSSScore float64 `bson:"cvss_score,omitempty"`
|
||||
Title string `bson:"title,omitempty"`
|
||||
References []string `bson:"references,omitempty"`
|
||||
|
||||
State string `bson:"state"` // open | fixed | accepted
|
||||
FirstSeen time.Time `bson:"first_seen"`
|
||||
LastSeen time.Time `bson:"last_seen"`
|
||||
FixedAt *time.Time `bson:"fixed_at,omitempty"`
|
||||
Accepted *Acceptance `bson:"accepted,omitempty"`
|
||||
}
|
||||
|
||||
type Acceptance struct {
|
||||
By primitive.ObjectID `bson:"by"`
|
||||
Reason string `bson:"reason"`
|
||||
Until time.Time `bson:"until"`
|
||||
At time.Time `bson:"at"`
|
||||
}
|
||||
```
|
||||
|
||||
Unique on `{instance_id, server_id, cve_id, package_name}`. That key is what
|
||||
makes a rescan an idempotent upsert rather than a duplicate factory, and it is
|
||||
what lets `first_seen` survive across scans. Query index
|
||||
`{instance_id, state, severity}`.
|
||||
|
||||
**An empty `FixedIn` is a real and common state** and must never be conflated
|
||||
with "not vulnerable". A CVE with no vendor fix published yet is exactly the
|
||||
finding people most need to see, and also the one that most needs acceptance,
|
||||
because there is nothing to patch.
|
||||
|
||||
Findings are **not deleted when a package is patched**. State moves to `fixed`
|
||||
with `fixed_at` set, so "what did we remediate last quarter" remains
|
||||
answerable — which is the question an auditor asks.
|
||||
|
||||
### `vulndb_meta` — singleton, deliberately unscoped
|
||||
|
||||
`db_version`, `pulled_at`, `last_full_scan_at`, `last_error`. It carries no
|
||||
`instance_id` because the vulnerability database is a property of the
|
||||
deployment, not of a tenant. Same reasoning as `migrations`.
|
||||
|
||||
### `vuln_alert_rules`
|
||||
|
||||
`instance_id`, `name`, `enabled`, `min_severity`, `tags map[string]string`,
|
||||
`channel_ids []`, timestamps.
|
||||
|
||||
The tag filter resolves through **`services.ResolveTargets`**, not a second
|
||||
matcher. That function is already the single answer to which servers a
|
||||
selector touches, and an alert rule that disagreed with a workflow about what
|
||||
`env:prod` means would be worse than having no filter at all.
|
||||
|
||||
---
|
||||
|
||||
## The matching engine
|
||||
|
||||
```
|
||||
server/internal/vulndb/
|
||||
pull.go OCI fetch → temp dir, version compare against vulndb_meta
|
||||
db.go BoltDB open, advisory lookup by (ecosystem, source, version)
|
||||
match.go per-family matching, severity resolution
|
||||
version.go dispatch to deb/rpm/apk comparator by OS family
|
||||
```
|
||||
|
||||
Dependencies: `github.com/aquasecurity/trivy-db` for the BoltDB schema, plus
|
||||
`go-deb-version`, `go-rpm-version` and `go-apk-version` — each a small
|
||||
standalone module doing one job. The roughly 200 lines of per-distro advisory
|
||||
lookup are ours.
|
||||
|
||||
Importing `trivy` itself was rejected: it would pull a very large transitive
|
||||
dependency tree into the server binary for one feature, and its Go API carries
|
||||
no stability guarantee across minor versions. Shelling out to the `trivy`
|
||||
binary against a generated SBOM was rejected for shipping a second binary in
|
||||
the image and turning a library call into subprocess lifecycle, timeouts and
|
||||
output-format drift.
|
||||
|
||||
### Why the comparators are bought rather than written
|
||||
|
||||
Version ordering is where this feature lives or dies, and its failure mode is
|
||||
silent. `dpkg` ordering has epochs, and `~` sorts *before* the empty string, so
|
||||
`3.0.2-0ubuntu1.15~rc1` precedes `3.0.2-0ubuntu1.15`. `rpmvercmp` has its own
|
||||
segment rules and treats `~` and `^` differently again. A `strings.Compare` or
|
||||
a semver parse orders `1.9` above `1.10` and reports a vulnerable fleet as
|
||||
clean — a false negative, which nobody notices until it matters.
|
||||
|
||||
### Scanning one server
|
||||
|
||||
1. Load `server_packages`; resolve OS family and version to a `trivy-db`
|
||||
ecosystem.
|
||||
2. **Unsupported ecosystem → record `status: unsupported`, clear the flag,
|
||||
write no findings.**
|
||||
3. For each package: resolve source name, look up advisories, compare versions.
|
||||
4. Upsert vulnerable results as `open`, preserving `first_seen`.
|
||||
5. Any currently-`open` finding absent from this result set → `fixed`, stamp
|
||||
`fixed_at`.
|
||||
6. Any `accepted` finding past its `until` → back to `open`.
|
||||
7. Clear `scan_pending`, stamp `scanned_at` and `db_version`.
|
||||
|
||||
Steps 5 and 6 must run in that order, so a finding that is both absent and
|
||||
expired settles as `fixed` rather than reopening on a package that no longer
|
||||
carries it.
|
||||
|
||||
Step 2 matters as much as any of the matching. Arch has no feed in `trivy-db`,
|
||||
so an Arch host must report **unsupported**, never "0 findings". Reporting
|
||||
clean when the truth is unknown is the same class of lie as a silently stale
|
||||
database, and it is the reason `vulndb_meta.pulled_at` appears on screen rather
|
||||
than only in a log.
|
||||
|
||||
### Severity
|
||||
|
||||
Resolved **vendor → NVD → unknown**, in that order, never invented.
|
||||
|
||||
This will surface as "why is this critical CVE marked low", and the answer is
|
||||
that Debian and Red Hat routinely downgrade an NVD score because the vulnerable
|
||||
code path is not reachable in their build. Their rating is the accurate one for
|
||||
that package, and showing NVD's above it would manufacture work that does not
|
||||
need doing.
|
||||
|
||||
---
|
||||
|
||||
## Findings lifecycle
|
||||
|
||||
`open | fixed | accepted`.
|
||||
|
||||
An accepted finding is suppressed from counts and alerts until its `until`
|
||||
date, then reopens automatically. A reason is required.
|
||||
|
||||
Acceptance with a mandatory expiry, rather than permanent dismissal, is what
|
||||
keeps the feature usable in both directions. Without any acceptance mechanism,
|
||||
a kernel CVE awaiting a reboot window sits red indefinitely and trains people
|
||||
to ignore the page. With permanent dismissal, accepted findings accumulate
|
||||
silently and nobody revisits them — the dismissal list becomes where risk goes
|
||||
to be forgotten, which is precisely what an auditor asks to see.
|
||||
|
||||
Retention: `settings.vuln_finding_retention_days`, a `*int` on the same pattern
|
||||
as `workflow_log_retention_days` — nil means 90 days, 0 means forever. Only
|
||||
`fixed` findings are swept, by a `StartVulnSweeper` inside the same
|
||||
`RunAsLeader("housekeeping", …)` as the existing sweepers. `open` and
|
||||
`accepted` findings are never swept at any setting.
|
||||
|
||||
---
|
||||
|
||||
## Alerting
|
||||
|
||||
Per-org rules over the existing `notification_channels`: severity threshold,
|
||||
optional tag filter, target channels.
|
||||
|
||||
A rescan emits one message summarising what newly opened — "12 new critical
|
||||
across 4 servers" — never one message per finding. See the leader section for
|
||||
why the tick boundary makes this structural.
|
||||
|
||||
Modelling findings as a monitor type was rejected. It would reuse monitors'
|
||||
state machine and channel wiring for free, but monitors are up/down for one
|
||||
endpoint with retries and hourly rollups, none of which means anything for a
|
||||
CVE; most fields would be disabled in the UI and the uptime graphs would be
|
||||
polluted with a signal that is not uptime.
|
||||
|
||||
This adds one `notify` payload type and a `vuln_digest.html.tmpl` /
|
||||
`vuln_digest.txt.tmpl` pair in `shared/mail`. Note that `shared/mail` templates
|
||||
are parsed in `init()`, so a mistyped field is a boot-time panic — CLAUDE.md
|
||||
describes a `render_test.go` guarding against exactly this, but **that file does
|
||||
not exist**; the repository has no Go tests at all, and by instruction this
|
||||
feature adds none. The template pair must therefore be verified by starting the
|
||||
binary and sending one digest through a real channel.
|
||||
|
||||
---
|
||||
|
||||
## Entitlement
|
||||
|
||||
The feature name is `vuln_scanning`, and it crosses the two services the way
|
||||
every other feature does:
|
||||
|
||||
- **admin** carries it as a per-instance entitlement toggle, so it can later be
|
||||
priced as a catalogue `feature` component without a second migration;
|
||||
- **the licence** snapshots it into `License.Features []string` at issue time;
|
||||
- **the server** asks `lic.HasFeature("vuln_scanning")` and never switches on
|
||||
tier, so changing what a tier includes needs no server release.
|
||||
|
||||
Off on Free.
|
||||
|
||||
**The gate is checked at `ReportPackages`, not at display.** Gating only the UI
|
||||
would still pay every write cost, and storage is the expensive half.
|
||||
|
||||
The agent learns of it through the existing 30-second `SyncKeys` poll:
|
||||
`SyncResponse` gains a `collect_packages` bool, and the hourly loop skips
|
||||
collection entirely when it is false. So an ungated instance produces no
|
||||
collection, no gRPC body, no document and no storage. `ReportPackages` still
|
||||
re-checks the entitlement server-side and refuses — the agent flag is an
|
||||
optimisation, the server check is the boundary.
|
||||
|
||||
Turning the feature off does not delete existing findings; they stop being
|
||||
served and stop updating. Deletion is the instance-deletion path's job.
|
||||
|
||||
---
|
||||
|
||||
## REST API
|
||||
|
||||
```
|
||||
GET /api/vulnerabilities # filter: severity, state, server, tags
|
||||
GET /api/vulnerabilities/summary # severity counts + database freshness
|
||||
POST /api/vulnerabilities/rescan # marks all scan_pending (owner|admin)
|
||||
POST /api/vulnerabilities/:id/accept # reason + until (owner|admin)
|
||||
DELETE /api/vulnerabilities/:id/accept # (owner|admin)
|
||||
GET /api/servers/:id/vulnerabilities
|
||||
GET /api/servers/:id/packages
|
||||
GET /api/packages/search?name= # fleet-wide
|
||||
GET,POST /api/vuln-rules · PUT,DELETE /api/vuln-rules/:id
|
||||
```
|
||||
|
||||
Every mutating path writes an audit event, as all of them do. Acceptance is the
|
||||
one decision people will be asked to justify, so `by`, `reason`, `until` and
|
||||
`at` land in the audit record and not only on the document.
|
||||
|
||||
---
|
||||
|
||||
## UI
|
||||
|
||||
`/vulnerabilities` is a fleet board **grouped by CVE** — one row per CVE with
|
||||
an affected-server count, expandable to the individual servers. The same CVE
|
||||
across 40 servers is one decision, and a flat list of findings makes it look
|
||||
like forty.
|
||||
|
||||
Server detail gains **Vulnerabilities** and **Packages** tabs. Alert rules go
|
||||
on `/settings/notifications`, beside the channels they consume.
|
||||
|
||||
Remediation introduces no new mechanism: a finding carrying `fixed_in` renders
|
||||
an **Apply updates** action calling the existing
|
||||
`POST /api/servers/:id/apply-updates`, which is already `ApplyUpdatesCmd`. See
|
||||
it, patch it, one place — and no second patching path to keep consistent with
|
||||
the first.
|
||||
|
||||
Database freshness is shown wherever findings are, not tucked into settings. A
|
||||
fleet scanning against a three-week-old database must say so rather than
|
||||
quietly report all-clear.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
**No automated tests.** The repository has none today, and by explicit
|
||||
instruction this feature adds none — no `*_test.go`, no frontend test files.
|
||||
That is a deliberate decision by the repository owner, recorded here so the
|
||||
absence reads as a choice rather than an omission.
|
||||
|
||||
It does change the risk profile, and the places it changes it are worth naming,
|
||||
because each fails by producing a **wrong answer rather than a crash**:
|
||||
|
||||
- **Version comparison.** The backport case — installed `1:3.0.2-0ubuntu1.15`
|
||||
against advisory fixed-in `1:3.0.2-0ubuntu1.15` resolving to *not
|
||||
vulnerable* — plus tilde ordering (`1.0~rc1` < `1.0`), epoch dominance
|
||||
(`1:1.0` > `2.0`) and `1.9` < `1.10`. Wrong here means a vulnerable fleet
|
||||
reported clean.
|
||||
- **Source-package fan-out.** One advisory against `openssl` must flag
|
||||
`libssl3`, `openssl` and `libssl-dev`. Matching on binary name alone silently
|
||||
finds one of three.
|
||||
- **`first_seen` preservation.** An upsert that overwrites it makes every
|
||||
finding look discovered today, and nothing surfaces that until someone reads
|
||||
a report.
|
||||
- **Fixed-before-reopen ordering.** A finding both absent from a scan and past
|
||||
its acceptance expiry must settle `fixed`, not reopen.
|
||||
|
||||
The implementation plan carries a manual verification table for each, to be
|
||||
walked before the relevant task is committed. They are the substitute for the
|
||||
tests, not a formality.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
| ------- | --------- |
|
||||
| Database pull fails | Keep the last good copy and serve stale. Record `last_error`, surface `pulled_at` age. **Never clear findings** — a network blip must not read as "all fixed" |
|
||||
| Unsupported distribution | `status: unsupported`, not zero findings |
|
||||
| Agent stops reporting | Findings persist and `collected_at` age is shown. No auto-expiry: a silent agent is not a patched server |
|
||||
| `trivy-db` schema version bumps | The puller refuses an unknown schema rather than mis-parsing it |
|
||||
| ghcr anonymous rate limit | Backoff; `VANTAGE_TRIVY_DB_REF` mirrors to a private registry |
|
||||
| Leadership lost mid-scan | The context is cancelled and the scan returns; `scan_pending` is still set, so the next leader picks it up |
|
||||
| Instance deleted | **`server_packages` and `vuln_findings` must be added to the control plane's instance-deletion collection list.** Easy to miss, and missing it orphans a tenant's package data indefinitely |
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Name | Required | Notes |
|
||||
| ---- | -------- | ----- |
|
||||
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for air-gapped installs or to avoid the anonymous ghcr rate limit |
|
||||
| `VANTAGE_VULNDB_DISABLED` | no | disables the puller and the scheduler entirely. Findings already written are still served and still marked stale |
|
||||
|
||||
---
|
||||
|
||||
## Deliberately out of scope
|
||||
|
||||
- **Windows.** Separate source, collector and matcher; its own spec.
|
||||
- **Container image scanning.** Sub-project C; needs the container registry.
|
||||
- **Compliance baseline assertions.** Sub-project D; shares this findings UI
|
||||
and nothing else.
|
||||
- **Language-level dependency scanning** (npm, pip, Go modules). `trivy-db`
|
||||
covers these ecosystems, but finding the manifests on a host is a different
|
||||
collection problem from asking the package manager what is installed.
|
||||
- **Automatic patching on a finding.** Remediation is one click, not zero. An
|
||||
unattended upgrade triggered by a CVE feed is a fleet-wide change driven by a
|
||||
third party's data, which is not a decision to take away from an operator.
|
||||
@@ -0,0 +1,441 @@
|
||||
# Workload registry
|
||||
|
||||
Date: 2026-08-06
|
||||
|
||||
Agents enumerate what each server actually runs — Docker containers, the
|
||||
compose stacks grouping them, and systemd services — and report it to the
|
||||
control plane. Containers and units can be started, stopped and restarted from
|
||||
the UI, and a bounded snapshot of their logs can be read without opening a
|
||||
console.
|
||||
|
||||
This is **sub-project B** of the four sketched in
|
||||
`2026-08-06-package-inventory-and-cve-findings-design.md`:
|
||||
|
||||
| # | Sub-project | Depends on |
|
||||
| - | ----------- | ---------- |
|
||||
| A | Package inventory + CVE findings — its own spec | nothing |
|
||||
| B | **Workload registry** — this spec | nothing |
|
||||
| C | Container image scanning | A and B |
|
||||
| D | Compliance profiles | shares A's findings UI only |
|
||||
|
||||
A and B are independent. C is the joiner and must not be designed before both
|
||||
exist: it needs B's image list and A's findings model.
|
||||
|
||||
**Workload** is the domain word throughout: one container or one systemd unit.
|
||||
It gives the collection, the commands and the page a single honest name rather
|
||||
than saying "container or service" in every identifier.
|
||||
|
||||
Scope is **Linux only**, matching sub-project A and the existing position that
|
||||
Windows agents are second-class by design. Docker runs on Windows; systemd does
|
||||
not, and half a feature per platform is worse than a clear line.
|
||||
|
||||
---
|
||||
|
||||
## What this is for
|
||||
|
||||
The control plane can manage a fleet's keys, run workflows across it and watch
|
||||
its endpoints, but it has no idea what any of those servers actually *runs*.
|
||||
"Restart nginx on that box" means opening a console. "Which of these 80 servers
|
||||
is still on the old image" is unanswerable.
|
||||
|
||||
---
|
||||
|
||||
## Reporting and refresh are one path
|
||||
|
||||
The agent reports on its own 60-second ticker through a `ReportWorkloads` RPC,
|
||||
using the same hash short-circuit as the package report: it offers a SHA-256 of
|
||||
the sorted workload list, and sends the body only when the server does not
|
||||
already hold that hash. An unchanged list costs one small message, which on a
|
||||
60-second cadence is the common case by a wide margin.
|
||||
|
||||
The on-demand refresh **does not return data**. `RefreshWorkloadsCmd` carries
|
||||
no payload back; it makes the agent report immediately through the normal RPC,
|
||||
and the UI refetches the stored document.
|
||||
|
||||
That is deliberate. A refresh that returned workloads inline would be a second
|
||||
writer for the same collection, arriving by a different route, with its own
|
||||
serialisation and its own opportunity to disagree with the periodic one. One
|
||||
writer, one shape; the refresh is a nudge, not a channel.
|
||||
|
||||
Opening a server's Workloads tab dispatches a refresh, so what is on screen is
|
||||
live rather than up to a minute stale. That matters because the page has a
|
||||
Restart button on it: a stale list is not merely a wrong impression, it is a
|
||||
wrong action aimed at a container that already died.
|
||||
|
||||
## What does answer back
|
||||
|
||||
Two operations genuinely return something:
|
||||
|
||||
| Command | Answers with |
|
||||
| ------- | ------------ |
|
||||
| `ControlWorkloadCmd{kind, id, action}` | the existing `CommandResult` — ok or error |
|
||||
| `WorkloadLogsCmd{kind, id, tail}` | a new `WorkloadLogsResult{command_id, text, truncated}` |
|
||||
|
||||
Both ride the proven path: `commandDispatcher.send()` for request and ack, and
|
||||
a `WorkloadResults` registry mirroring `StepResults.Await`/`Deliver` over the
|
||||
bus. **`Await` must subscribe before the command is dispatched** — the pod
|
||||
driving the request is usually not the pod holding the agent's stream, and a
|
||||
fast agent otherwise answers into a channel nobody has joined. This is not a
|
||||
new hazard; it is the one `stepresults.go` already documents.
|
||||
|
||||
```protobuf
|
||||
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
|
||||
|
||||
message ReportWorkloadsRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
string hash = 3;
|
||||
bool docker_ok = 4;
|
||||
string docker_error = 5;
|
||||
bool systemd_ok = 6;
|
||||
string systemd_error = 7;
|
||||
repeated Workload workloads = 8; // empty on the offer call
|
||||
}
|
||||
|
||||
message ReportWorkloadsResponse {
|
||||
bool need_full = 1;
|
||||
}
|
||||
|
||||
// ServerCommand gains three variants.
|
||||
message RefreshWorkloadsCmd {}
|
||||
|
||||
message ControlWorkloadCmd {
|
||||
string kind = 1; // "container" | "unit"
|
||||
string id = 2;
|
||||
string action = 3; // "start" | "stop" | "restart"
|
||||
}
|
||||
|
||||
message WorkloadLogsCmd {
|
||||
string kind = 1;
|
||||
string id = 2;
|
||||
int32 tail = 3;
|
||||
}
|
||||
|
||||
// AgentMessage gains one variant.
|
||||
message WorkloadLogsResult {
|
||||
string command_id = 1;
|
||||
string text = 2;
|
||||
bool truncated = 3;
|
||||
string error = 4;
|
||||
}
|
||||
```
|
||||
|
||||
The offer-then-send handshake is the package report's, unchanged: the agent
|
||||
calls once with `workloads` empty, and resends with the body only if the
|
||||
response sets `need_full`.
|
||||
|
||||
An agent whose stream no pod holds gets a 503 from the dispatcher, as
|
||||
everything else does. Commands are not queued: a command whose owner died must
|
||||
fail loudly rather than be delivered to nobody while the operator is told it
|
||||
worked.
|
||||
|
||||
---
|
||||
|
||||
## Not gated by licence
|
||||
|
||||
Unlike CVE scanning, this reads as core fleet management rather than a premium
|
||||
add-on, so v1 ships to every instance with no entitlement check.
|
||||
|
||||
If that changes it is a one-line `HasFeature` check at `ReportWorkloads`,
|
||||
gating collection rather than display — the same placement and the same
|
||||
reasoning as sub-project A, where gating the UI alone would still pay every
|
||||
write cost.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
One new collection, `server_workloads`, one document per server, mirroring
|
||||
`server_packages`.
|
||||
|
||||
```go
|
||||
type ServerWorkloads struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
InstanceID string `bson:"instance_id" json:"-"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
Hash string `bson:"hash" json:"hash"`
|
||||
Workloads []Workload `bson:"workloads" json:"workloads"`
|
||||
CollectedAt time.Time `bson:"collected_at" json:"collected_at"`
|
||||
|
||||
DockerOK bool `bson:"docker_ok" json:"docker_ok"`
|
||||
DockerError string `bson:"docker_error,omitempty" json:"docker_error,omitempty"`
|
||||
SystemdOK bool `bson:"systemd_ok" json:"systemd_ok"`
|
||||
SystemdError string `bson:"systemd_error,omitempty" json:"systemd_error,omitempty"`
|
||||
}
|
||||
|
||||
type Workload struct {
|
||||
Kind string `bson:"kind" json:"kind"` // "container" | "unit"
|
||||
ID string `bson:"id" json:"id"` // container id, or unit name
|
||||
Name string `bson:"name" json:"name"`
|
||||
State string `bson:"state" json:"state"`
|
||||
Health string `bson:"health,omitempty" json:"health,omitempty"`
|
||||
Image string `bson:"image,omitempty" json:"image,omitempty"`
|
||||
Stack string `bson:"stack,omitempty" json:"stack,omitempty"`
|
||||
Ports []string `bson:"ports,omitempty" json:"ports,omitempty"`
|
||||
Restarts int `bson:"restarts,omitempty" json:"restarts,omitempty"`
|
||||
StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
|
||||
Protected bool `bson:"protected" json:"protected"`
|
||||
}
|
||||
```
|
||||
|
||||
`State` is normalised across the two kinds: containers report `running`,
|
||||
`exited`, `paused`, `restarting`, `created`; units report `active`, `inactive`,
|
||||
`failed`, `activating`. They are deliberately **not** collapsed into a shared
|
||||
vocabulary — a failed unit and an exited container mean different things, and
|
||||
flattening them would lose the distinction the operator needs.
|
||||
|
||||
Indexes: `{instance_id, server_id}` unique, plus multikey
|
||||
`{instance_id, "workloads.image"}` for the fleet-wide "which servers run image
|
||||
X" query.
|
||||
|
||||
### Why the OK/Error pairs exist
|
||||
|
||||
A host with no Docker installed and a host where Docker is installed and
|
||||
running nothing both produce an empty list. One should read "not in use here",
|
||||
the other "nothing running", and only the second deserves any alarm.
|
||||
|
||||
The error strings separate a third case the booleans alone cannot: Docker
|
||||
installed with the daemon down. "Not installed" and "installed but not
|
||||
responding" are different problems with different fixes, and collapsing them
|
||||
into one false boolean throws away the only thing that tells them apart.
|
||||
|
||||
### Why `Protected` is reported rather than derived
|
||||
|
||||
The agent already knows which unit and container it is. Sending that up lets
|
||||
the UI render the action disabled with a reason instead of offering a button
|
||||
whose refusal is already known.
|
||||
|
||||
The field is the courtesy; the agent's own check is the boundary. See the
|
||||
control section.
|
||||
|
||||
### No history
|
||||
|
||||
A workload list is state, not a record. Nobody asks what containers ran last
|
||||
Tuesday, and keeping it would grow a collection per server per minute in
|
||||
exchange for a question nobody has.
|
||||
|
||||
---
|
||||
|
||||
## Collectors
|
||||
|
||||
### Docker: two commands, no English parsing
|
||||
|
||||
```
|
||||
docker ps -aq
|
||||
docker inspect --format '{{json .}}' <ids…>
|
||||
```
|
||||
|
||||
Not `docker ps --format '{{json .}}'` alone. That reports health and uptime
|
||||
inside a human `Status` string — `"Up 2 hours (healthy)"` — and anything built
|
||||
on it is parsing English that is localised, reworded between releases, and
|
||||
silently different for a paused or restarting container. `inspect` returns
|
||||
`State.Health.Status`, `State.StartedAt` and `RestartCount` as typed fields.
|
||||
Two execs instead of one, and no parser to be wrong.
|
||||
|
||||
`RestartCount` justifies the second call by itself: a container cycling is the
|
||||
single thing this page most needs to show, and it is invisible in a list that
|
||||
only ever says "Up".
|
||||
|
||||
Compose stacks come from the `com.docker.compose.project` label. **No YAML is
|
||||
read from disk** — the label is what Docker itself treats as authoritative, and
|
||||
a compose file on disk may not be what is actually running.
|
||||
|
||||
Docker absent, or a socket that cannot be reached, sets `DockerOK: false`. It
|
||||
is not an error and produces no log line: most servers in a fleet built around
|
||||
SSH key management will not have Docker, and treating the normal case as a
|
||||
fault makes the feature look broken on the majority of the estate.
|
||||
|
||||
### systemd: filtered on purpose
|
||||
|
||||
```
|
||||
systemctl list-units --type=service --state=running,failed --no-legend --plain --no-pager
|
||||
systemctl list-unit-files --type=service --state=enabled --no-legend --plain --no-pager
|
||||
```
|
||||
|
||||
Two calls because "running or failed" and "enabled but stopped" are different
|
||||
questions, and an enabled unit that is not running is exactly the one worth
|
||||
seeing.
|
||||
|
||||
Excluded by prefix: `systemd-`, `user@`, `session-`, `init.scope`. A typical
|
||||
host carries 300+ units, the platform's own accounting for most of them.
|
||||
Listing all of them buries the ten anyone cares about — the same failure mode
|
||||
as an unfiltered vulnerability report, and the same fix.
|
||||
|
||||
Column output rather than `--output=json`: the JSON flag requires systemd 246+,
|
||||
and this fleet includes older stable distributions. The column format has been
|
||||
stable considerably longer than the JSON one has existed.
|
||||
|
||||
---
|
||||
|
||||
## Control actions
|
||||
|
||||
```
|
||||
container: docker {start|stop|restart} <id>
|
||||
unit: systemctl {start|stop|restart} <unit>
|
||||
```
|
||||
|
||||
Owner or admin only. Every action writes an audit event naming the actor, the
|
||||
server and the target.
|
||||
|
||||
### The protected set
|
||||
|
||||
Computed agent-side: `vantage-agent.service`, plus the container ID read from
|
||||
`/proc/self/cgroup` should the agent ever be run inside a container.
|
||||
|
||||
The agent refuses those before doing anything. As with the console relay
|
||||
hardcoding `127.0.0.1` agent-side, **the control plane may name a target, but
|
||||
the agent decides what it will do to itself**. A server-side denylist alone
|
||||
would be bypassed by the next dispatch path someone adds, and the failure is
|
||||
unrecoverable from the UI: a server that stops its own agent goes offline, and
|
||||
the way back is SSH or physical access — precisely what this feature exists to
|
||||
avoid needing.
|
||||
|
||||
### Timeouts
|
||||
|
||||
`docker stop` waits on a container that may ignore SIGTERM. `systemctl stop`
|
||||
on a unit with a long `TimeoutStopSec` blocks for exactly as long as that says.
|
||||
Both run under a 90-second context, and a timeout returns a real error rather
|
||||
than an ack implying success.
|
||||
|
||||
---
|
||||
|
||||
## Logs
|
||||
|
||||
```
|
||||
container: docker logs --tail 500 --timestamps <id>
|
||||
unit: journalctl -u <unit> -n 500 --no-pager --output=short-iso
|
||||
```
|
||||
|
||||
Capped at **500 lines and 256KB, whichever binds first**, with `truncated` set
|
||||
so the UI can say so. Two caps because 500 lines of a container emitting 4KB
|
||||
JSON blobs is 2MB, and a line count alone does not stop it — the same reasoning
|
||||
that gave workflow logs both a per-line and a per-run cap.
|
||||
|
||||
Live following is deliberately absent. The browser console already offers a
|
||||
real terminal on the same server, where `docker logs -f` works properly with
|
||||
its own scrollback and cancellation. Building a second streaming path — a
|
||||
relay listener, proxy bus keys, a WebSocket upgrade and a cancellation story
|
||||
for a follow nobody closed — to duplicate that would be a large amount of
|
||||
machinery aimed at a capability already shipped. A bounded snapshot answers
|
||||
"why did this restart", which is the question that sends people to the console
|
||||
in the first place.
|
||||
|
||||
### Log reads are owner or admin only, and audited
|
||||
|
||||
Unlike workflow logs, these cannot be masked. A workflow's logs can be masked
|
||||
because the run injected the secrets and therefore knows their values. A
|
||||
container's stdout is arbitrary and may contain credentials nobody declared —
|
||||
a connection string in a startup banner, a token in a stack trace.
|
||||
|
||||
So log reads sit behind the same role check as control actions and are audited.
|
||||
A member who can see the fleet cannot read its logs. This is a deliberate
|
||||
access decision, not an oversight, and it is why log reading is not simply
|
||||
folded in with the read-only snapshot endpoints.
|
||||
|
||||
---
|
||||
|
||||
## REST API
|
||||
|
||||
```
|
||||
GET /api/servers/:id/workloads # stored snapshot
|
||||
POST /api/servers/:id/workloads/refresh # dispatch, then refetch
|
||||
POST /api/servers/:id/workloads/:wid/action # {"action":"start|stop|restart"} (owner|admin)
|
||||
GET /api/servers/:id/workloads/:wid/logs?tail= # (owner|admin)
|
||||
GET /api/workloads?image=&stack=&state= # fleet-wide
|
||||
```
|
||||
|
||||
`:wid` is a container ID or a unit name, URL-encoded. Unit names carry dots and
|
||||
`@`, which are legal in a path segment but not worth relying on unencoded.
|
||||
|
||||
`tail` is clamped to the 500-line cap server-side; a client asking for more
|
||||
gets 500, not an error.
|
||||
|
||||
---
|
||||
|
||||
## UI
|
||||
|
||||
Server detail gains a **Workloads** tab, ordered compose stacks first — grouped
|
||||
under the stack name — then loose containers, then units.
|
||||
|
||||
That ordering is not cosmetic. A stack is one thing to an operator even when it
|
||||
is six containers, and a flat list turns one decision into six rows. It is the
|
||||
same argument that groups the vulnerabilities board by CVE rather than by
|
||||
finding.
|
||||
|
||||
A `/workloads` fleet view answers "which servers run image X", which is the
|
||||
reason the snapshot is stored at all rather than fetched on demand and
|
||||
discarded.
|
||||
|
||||
Three rules that follow directly from the model:
|
||||
|
||||
- **Protected rows render their actions disabled, with the reason**, rather
|
||||
than offering a button whose refusal is already known.
|
||||
- **`DockerOK: false` reads "Docker not in use on this server"**, never an
|
||||
empty list, and `DockerError` when present is shown as a distinct problem.
|
||||
- State never reads by colour alone: every pill carries a distinct shape and a
|
||||
text label, matching the existing monitor and severity pills.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
**No automated tests.** The repository has none today, and by explicit
|
||||
instruction this feature adds none — no `*_test.go`, no frontend test files.
|
||||
A deliberate decision by the repository owner, recorded so the absence reads as
|
||||
a choice rather than an omission.
|
||||
|
||||
The behaviours that would otherwise have been tested are the ones that fail
|
||||
quietly, and the implementation plan carries a manual check for each:
|
||||
|
||||
- **Parser output against real command output.** `docker inspect` must yield
|
||||
`RestartCount`, health and the compose label as `Stack`; the `systemctl`
|
||||
exclusion filter must drop `systemd-*` and `user@*` while keeping
|
||||
`nginx.service`. Both are verified against a live host rather than a fixture.
|
||||
- **Protected-set computation.** `vantage-agent.service` marked,
|
||||
`nginx.service` not. Getting this wrong in the permissive direction lets a
|
||||
server stop its own agent, which is unrecoverable from the UI.
|
||||
- **Hash order-independence.** An ordering-sensitive hash resends the full list
|
||||
every 60 seconds, which is invisible except as traffic.
|
||||
- **Log capping in both directions.** 600 lines in → 500 out with `truncated`;
|
||||
a 300KB blob of fewer than 500 lines → capped, `truncated`. The second is the
|
||||
case a line-count-only implementation silently fails, and it fails by sending
|
||||
megabytes rather than by erroring.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
| ------- | --------- |
|
||||
| Docker not installed | `DockerOK: false`, no error, UI reads "not in use" |
|
||||
| Docker installed, daemon down | `DockerOK: false` **plus** `DockerError` — different message, different fix |
|
||||
| Agent offline | 503 from the existing dispatcher. No queueing: a command whose owner died must fail loudly |
|
||||
| Action on a protected workload | Agent refuses; API answers 409 naming the reason |
|
||||
| `stop` exceeds its timeout | Real error surfaced, never a hopeful ack. Snapshot refreshed afterwards |
|
||||
| Container removed between snapshot and action | Docker's "No such container" surfaced and a refresh dispatched — this is what on-demand refresh is for |
|
||||
| Log exceeds either cap | Truncated, flagged, and stated in the UI |
|
||||
| Instance deleted | **`server_workloads` must be added to the control plane's instance-deletion collection list**, alongside sub-project A's two collections |
|
||||
|
||||
---
|
||||
|
||||
## Deliberately out of scope
|
||||
|
||||
- **Live log following.** The console already does it. See the logs section.
|
||||
- **Creating, deleting or updating containers and units.** This is a control
|
||||
and visibility surface, not a deployment tool — workflows already exist for
|
||||
changing what a server runs, with snapshots, audit and rollback.
|
||||
- **`docker exec` into a container.** The console reaches the host; exec from
|
||||
the control plane is a second remote-execution path with its own audit and
|
||||
authorisation story, and it belongs in its own spec if anywhere.
|
||||
- **Kubernetes and containerd.** The Docker collector shells to the `docker`
|
||||
CLI, so a node whose runtime is containerd or CRI-O reports nothing from it —
|
||||
`DockerOK: false`, correctly, since Docker genuinely is not in use. Covering
|
||||
those runtimes means a `crictl`/`nerdctl` collector, and talking to a
|
||||
Kubernetes API server is a different subsystem again. Neither is v1.
|
||||
- **Podman as a supported runtime.** Its `docker`-compatible CLI means an
|
||||
aliased install will largely work, and that is a happy accident rather than a
|
||||
claim: nothing here is tested against Podman and its `RestartCount` and
|
||||
compose-label behaviour are not verified.
|
||||
- **Windows.** No systemd, and a different container story.
|
||||
- **Image vulnerability scanning.** Sub-project C, which needs this spec's
|
||||
image list and sub-project A's findings model.
|
||||
@@ -0,0 +1,297 @@
|
||||
# API tokens and OpenAPI reference
|
||||
|
||||
Date: 2026-08-12
|
||||
Status: approved, ready for implementation planning
|
||||
|
||||
## Problem
|
||||
|
||||
The only programmatic credential the control plane issues is the ESO secrets-read
|
||||
bearer token, which reaches exactly one endpoint. Everything else requires a
|
||||
browser session cookie. There is therefore no supported way to drive Vantage from
|
||||
CI, a script, or infrastructure-as-code, and no machine-readable description of
|
||||
the REST API for anyone who wants to try.
|
||||
|
||||
This spec covers two deliverables that ship together: scoped API tokens, and an
|
||||
OpenAPI 3.1 document rendered as a live reference page. A Terraform provider is
|
||||
the intended follow-on and is explicitly out of scope here — it depends on both
|
||||
of these being settled, and it is a separate Go module with its own release
|
||||
cycle.
|
||||
|
||||
## Goals
|
||||
|
||||
- A person can mint a scoped, optionally expiring token and use it against the
|
||||
existing REST API with no new endpoints to learn.
|
||||
- A leaked token is bounded by role, by scope, and by expiry policy.
|
||||
- Offboarding a person removes their tokens as a side effect of removing them.
|
||||
- The API has a machine-readable description that cannot silently drift from the
|
||||
handlers it describes.
|
||||
- The reference page works on an air-gapped self-hosted install.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Token editing. Role and scopes are immutable; rotation replaces amendment.
|
||||
- OAuth device flow or any browser-based authorisation grant.
|
||||
- Per-server or per-tag restrictions on a token.
|
||||
- Instance-owned service tokens that outlive their creator.
|
||||
- The Terraform provider.
|
||||
- General API rate limiting beyond the per-token limit described below.
|
||||
|
||||
## Part 1 — API tokens
|
||||
|
||||
### Token format and storage
|
||||
|
||||
A token is `vt_` followed by 32 random bytes, hex encoded. It is displayed once,
|
||||
at creation, and never again.
|
||||
|
||||
Only the SHA-256 hash is stored, in a unique index. This follows the precedent
|
||||
already set by `servers.agent_token_hash` and the ESO read token. bcrypt is
|
||||
deliberately not used: the value is full-entropy random rather than a
|
||||
user-chosen password, so a fast hash is sufficient, and a per-token salt would
|
||||
force a collection scan where an indexed lookup is wanted.
|
||||
|
||||
The first eight characters are stored in clear as `hint`, so the list can
|
||||
identify a token without revealing it.
|
||||
|
||||
### Authentication path
|
||||
|
||||
`auth.Middleware()` gains a fallback. When there is no `km_session` cookie it
|
||||
looks for `Authorization: Bearer vt_…`. Both paths end by placing a `*Session` in
|
||||
the gin context, so every handler, `auth.RequireRole`, `RequireActiveLicense`,
|
||||
`RequireFeature` and `actorFromCtx` continue to work unmodified.
|
||||
|
||||
```
|
||||
Session{
|
||||
UserID: token.UserID
|
||||
InstanceID: token.InstanceID
|
||||
Role: min(user.Role, token.Role) // owner > admin > member
|
||||
Email: user.Email
|
||||
TokenID: token.TokenID // "" for cookie sessions
|
||||
Scopes: token.Scopes // nil for cookie sessions
|
||||
}
|
||||
```
|
||||
|
||||
The effective role is recomputed on every request rather than frozen at
|
||||
creation. Demoting the user demotes the token with them. No caching is required
|
||||
because the user document is already read to confirm the user still exists.
|
||||
|
||||
The existing host guard applies identically. A token carries an `instance_id`,
|
||||
and a request arriving at a different instance's host is rejected exactly as a
|
||||
mismatched cookie session is. The tenant boundary must not have a token-shaped
|
||||
hole in it.
|
||||
|
||||
`last_used_at` is written best-effort and only when the stored value is more
|
||||
than 60 seconds old, so it does not become a Mongo write per request.
|
||||
|
||||
Rejections:
|
||||
|
||||
| Condition | Status | Body |
|
||||
| -------------------- | ------ | -------------------------------------- |
|
||||
| No credential at all | 401 | `not authenticated` |
|
||||
| Unknown token | 401 | `invalid token` |
|
||||
| Expired token | 401 | `code: token_expired` |
|
||||
| Owning user deleted | 401 | `invalid token` |
|
||||
| Missing scope | 403 | names the required scope |
|
||||
| Wrong instance host | 403 | `instance host mismatch` |
|
||||
|
||||
### Data model
|
||||
|
||||
New collection `api_tokens`, added to `services.ScopedCollections` so instance
|
||||
purge reaches it.
|
||||
|
||||
```
|
||||
instance_id string
|
||||
token_id string
|
||||
user_id string
|
||||
name string 1-64 chars, unique per user
|
||||
hint string first 8 chars of the plaintext
|
||||
token_hash string sha256
|
||||
role string owner|admin|member
|
||||
scopes []string
|
||||
expires_at *time.Time nil means never
|
||||
created_at time.Time
|
||||
last_used_at *time.Time
|
||||
created_by_ip string
|
||||
```
|
||||
|
||||
Indexes: unique on `token_hash`; compound on `(instance_id, user_id)`.
|
||||
|
||||
Deleting a user deletes their tokens as part of the same service call as
|
||||
`DeleteInstanceUser`, so offboarding is one action rather than two.
|
||||
|
||||
### Expiry policy
|
||||
|
||||
Expiry is optional by default: a token may be created with no expiry at all.
|
||||
Instance settings gain `api_token_max_days *int`, editable by owner and admin:
|
||||
|
||||
- `nil` — no cap; never-expire is allowed. This is the default, so an upgrade
|
||||
changes nothing.
|
||||
- `n > 0` — a new token must expire within `n` days, and a never-expire token is
|
||||
refused.
|
||||
|
||||
Changing the setting does not retroactively invalidate existing tokens; it is a
|
||||
policy on issuance. Tokens already outside the new cap are flagged in the UI so
|
||||
that someone can rotate them deliberately, rather than discovering the change
|
||||
when a pipeline breaks.
|
||||
|
||||
### Scopes
|
||||
|
||||
Eight resources, each with `:read` and `:write`. Write implies read on the same
|
||||
resource.
|
||||
|
||||
```
|
||||
servers keys secrets workflows
|
||||
monitors vulns workloads settings
|
||||
```
|
||||
|
||||
Scope enforcement is a single middleware, `RequireScopes()`, mounted once in the
|
||||
`/api` stack. It derives the required resource from the matched gin route
|
||||
pattern using a map, rather than from a per-route decorator: a route registered
|
||||
without a decorator would otherwise be unguarded, and this repo already prefers
|
||||
guards that come from where a route is mounted rather than from someone
|
||||
remembering.
|
||||
|
||||
- Cookie sessions skip the check entirely.
|
||||
- A token-authenticated request whose route pattern is absent from the map is
|
||||
denied with 403. Fail closed.
|
||||
- A startup check fails boot if any registered `/api` route pattern is missing
|
||||
from the map, so the failure surfaces at deploy rather than at the first call.
|
||||
|
||||
Deliberate placements:
|
||||
|
||||
- `keys:read` covers `GET /keys/:id/private-key`. Reading a private key is
|
||||
reading a key.
|
||||
- `secrets:read` does not cover `GET /api/secrets/:group/values`. That endpoint
|
||||
keeps its separate ESO bearer path and is unaffected by this work.
|
||||
- `workloads:write` covers both container control actions and log reads, which
|
||||
are already restricted to owner and admin.
|
||||
- The token endpoints themselves map to the `settings` resource: `GET
|
||||
/api/tokens` requires `settings:read`, and `POST` and `DELETE` require
|
||||
`settings:write`. A token can therefore mint or revoke tokens only when
|
||||
explicitly granted that scope, and never above its own role.
|
||||
|
||||
### Endpoints
|
||||
|
||||
```
|
||||
GET /api/tokens list; a member sees their own, owner|admin see all
|
||||
POST /api/tokens create; returns the plaintext once
|
||||
DELETE /api/tokens/:id revoke; own always, owner|admin any
|
||||
```
|
||||
|
||||
There is no `PUT`. Editing a token's role or scopes changes what a credential
|
||||
already deployed in a CI system can do, with no record of what it could do
|
||||
before. Rotation replaces amendment.
|
||||
|
||||
`POST` body: `name`, `role`, `scopes[]`, `expires_in_days` (omitted means never,
|
||||
and is refused when `api_token_max_days` is set).
|
||||
|
||||
Refusals: 400 for an unknown scope, 409 for a duplicate name for that user, 403
|
||||
for a role above the creator's own, 422 for an expiry beyond policy.
|
||||
|
||||
### Web UI
|
||||
|
||||
A new "API tokens" card in the Access group of `/settings`, alongside Members
|
||||
and single sign-on. Not a new nav entry — `/settings/instance` was folded back
|
||||
into `/settings` for precisely this reason, and the card lives in
|
||||
`web/components/settings/` with the others, reusing the shared `Field` and
|
||||
`inputClass`.
|
||||
|
||||
The card lists name, hint, role, scope chips, last used, and expiry with a
|
||||
distinct state for expired and for over-policy. Revoke is per row and confirms.
|
||||
|
||||
Create opens a modal. The plaintext is shown once in a `--well` block with
|
||||
copy-to-clipboard and an explicit line saying it will not be shown again.
|
||||
|
||||
Members see only their own rows. Owner and admin get an "All tokens" toggle.
|
||||
|
||||
`api_token_max_days` is a field on the same card, visible to owner and admin
|
||||
only.
|
||||
|
||||
### Audit
|
||||
|
||||
New events:
|
||||
|
||||
- `token.created`
|
||||
- `token.revoked`
|
||||
- `token.expired_use` — a rejected expired token, which is how a forgotten CI
|
||||
job becomes visible
|
||||
- `settings.token_policy_updated`
|
||||
|
||||
The actor is the human's email throughout, so `actorFromCtx` needs no change.
|
||||
Every existing audit event written during a token-authenticated request gains
|
||||
`via: "token:<name>"` in its detail, so the log distinguishes a person clicking
|
||||
from their credential acting.
|
||||
|
||||
### Rate limiting
|
||||
|
||||
Token-authenticated requests are limited per token in Redis at 600 per minute,
|
||||
answering 429 with `Retry-After`. Cookie sessions are untouched. This is narrow
|
||||
on purpose: it is not the general API rate-limiting project, only enough that a
|
||||
runaway script cannot take an instance down.
|
||||
|
||||
## Part 2 — OpenAPI and the reference page
|
||||
|
||||
### Generation
|
||||
|
||||
`swaggo/swag` v2, pinned, emitting OpenAPI 3.1. v1 emits Swagger 2.0, which
|
||||
Scalar renders poorly.
|
||||
|
||||
Handlers in `server/internal/api/*.go` gain annotation comments. Request and
|
||||
response bodies that are currently anonymous inline structs become named
|
||||
structs. This is real churn across roughly fifteen files and is the honest cost
|
||||
of choosing generation over a hand-written document.
|
||||
|
||||
The generated `server/internal/api/docs/openapi.json` is committed and embedded
|
||||
with `go:embed`, not generated during the image build: `server/Dockerfile`
|
||||
produces a `scratch` runtime from a Go build stage, and adding codegen there
|
||||
means putting the toolchain in the build image.
|
||||
|
||||
`server-deploy.yml` gains a check that regenerates the spec and runs
|
||||
`git diff --exit-code`. An annotation edited without regenerating fails the
|
||||
build. Without this check the annotations are worth less than a hand-written
|
||||
document, because they would drift while appearing authoritative.
|
||||
|
||||
### Serving
|
||||
|
||||
```
|
||||
GET /api/openapi.json the spec, session or token authenticated
|
||||
GET /api/docs HTML page loading a vendored Scalar bundle
|
||||
```
|
||||
|
||||
The Scalar standalone bundle is vendored under `server/internal/api/docs/`, with
|
||||
its version recorded in a comment beside it and refreshed by hand. No CDN:
|
||||
air-gapped self-hosted installs are supported, and a reference page that fails
|
||||
closed on an offline site is a support ticket.
|
||||
|
||||
Because the page is served by the instance itself, "Try it" acts against the
|
||||
reader's own API with their own session.
|
||||
|
||||
### Documented auth schemes
|
||||
|
||||
Three, kept distinct:
|
||||
|
||||
- `cookieAuth` — the `km_session` cookie.
|
||||
- `bearerAuth` — a `vt_…` API token.
|
||||
- The ESO secrets endpoint is marked as its own separate scheme, so nobody wires
|
||||
a personal access token into External Secrets Operator.
|
||||
|
||||
## Documentation
|
||||
|
||||
- `docsite/docs/reference/api-tokens.md`: creating a token, the scope table,
|
||||
curl examples, rotation, and the maximum-lifetime policy.
|
||||
- `CLAUDE.md`: the three token routes under REST API, the `api_tokens`
|
||||
collection, and a note that `openapi.json` is generated and CI-verified.
|
||||
|
||||
## Risks
|
||||
|
||||
- The anonymous-struct-to-named-struct conversion is the bulk of the work and
|
||||
touches handler code this feature otherwise has no business in.
|
||||
- The vendored Scalar bundle is a manual refresh that nobody will remember. The
|
||||
version comment is the only mitigation.
|
||||
- A scope map keyed on gin route patterns breaks if a route path is renamed. The
|
||||
boot-time completeness check is what turns that into a startup failure rather
|
||||
than a silent 403 in production.
|
||||
|
||||
## Follow-on work
|
||||
|
||||
A Terraform provider, as its own spec and plan, consuming the tokens and the
|
||||
OpenAPI document produced here.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Instance rename in Vantage HQ
|
||||
|
||||
**Date:** 2026-08-12
|
||||
**Status:** approved, not yet implemented
|
||||
|
||||
## Problem
|
||||
|
||||
A cloud instance is named once, at creation, and never again. The name is
|
||||
chosen in the first thirty seconds of a customer's relationship with the
|
||||
product — before they have decided whether this is "Acme" or "Acme
|
||||
Production" — and it is the name that becomes their DNS host, appears in every
|
||||
sign-in link and heads every page of their control plane. Today the only way to
|
||||
change it is to create a second instance and move, or to open a support ticket
|
||||
that has no tooling behind it.
|
||||
|
||||
## What a rename is
|
||||
|
||||
One customer-initiated action on a **cloud** instance: a new name, from which a
|
||||
new slug is derived, which moves the instance to a new DNS host.
|
||||
|
||||
Name and slug move together. The slug is re-derived through
|
||||
`provision.BaseSlug`, so the rules that named the instance at creation are the
|
||||
rules that rename it — the same reserved-label list, the same 3–40 character
|
||||
bound, the same `Slugify` collapse of non-alphanumeric runs. There is no
|
||||
separate slug field for the customer to edit, because two fields invite the
|
||||
state where the name says one thing and the host says another, and that
|
||||
divergence is exactly what a rename exists to fix.
|
||||
|
||||
A licence binds an instance **UUID**, not a slug. A rename therefore issues no
|
||||
licence, calls Paddle not at all, and consumes no relink. This is the property
|
||||
that makes the whole feature cheap, and it should be stated in any future change
|
||||
that tempts someone to touch the licence from this path.
|
||||
|
||||
### What breaks, deliberately
|
||||
|
||||
- **The old host stops working.** The old slug is released the moment the rename
|
||||
commits; another account may take it. Bookmarks, saved sign-in links and any
|
||||
agent install one-liner that named the web host are stale. Agents themselves
|
||||
are unaffected — they dial `GRPC_HOST`, which is not per-tenant.
|
||||
- **The old host keeps working for up to 60 seconds.** `server/internal/auth/instancehost.go`
|
||||
caches slug-to-instance lookups for 60s, and admin has no path to invalidate
|
||||
another process's memory. The released slug can be claimed by another account
|
||||
inside that window, so for up to a minute a replica still maps that host to the
|
||||
previous tenant. No data is exposed — the host/session guard rejects a session
|
||||
belonging to a different instance — but the new owner's users can briefly reach
|
||||
the old tenant's instance on their own host, and see its login page rather than
|
||||
theirs. Adding a cross-service invalidation channel for a 60-second window is
|
||||
not worth the coupling.
|
||||
- **The customer must sign in again.** `km_session` is set with no `Domain`
|
||||
attribute, so it is host-only and does not follow the instance to its new
|
||||
subdomain. The UI says so rather than letting the customer discover it.
|
||||
|
||||
## Scope
|
||||
|
||||
| | Customer (owner or admin) | Staff |
|
||||
|---|---|---|
|
||||
| Cloud instance | rename, 24h cooldown | rename, no cooldown |
|
||||
| Self-hosted instance | refused, 400 | name only; there is no slug |
|
||||
| Cloud placeholder | refused, 409 | refused, 409 |
|
||||
|
||||
Self-hosted is refused on the customer side for the same reason the member
|
||||
endpoints refuse it: there is no control-plane row to write. The install is the
|
||||
customer's, on their own database, and admin cannot reach it. Staff may still
|
||||
correct the label on admin's own row, because that label is what staff search
|
||||
by.
|
||||
|
||||
## Data flow
|
||||
|
||||
Two writes, in this order:
|
||||
|
||||
1. **Control plane `instances`** — `{name, slug}`.
|
||||
2. **Admin `admin_instances`** — `{name, slug, renamed_at}`.
|
||||
|
||||
The control plane goes first because `instances.slug` carries the unique index,
|
||||
and that index is what actually decides a race between two accounts reaching for
|
||||
the same name. Deciding it anywhere else would be guessing.
|
||||
|
||||
If the second write fails, the first is rolled back best-effort — restoring the
|
||||
previous name and slug — and the request answers 500. Leaving them divergent
|
||||
would have HQ print a host that is not the host, which is worse than a failed
|
||||
rename.
|
||||
|
||||
## Backend
|
||||
|
||||
### `shared/provision/instance.go`
|
||||
|
||||
```go
|
||||
// ErrSlugTaken means the derived slug belongs to another instance.
|
||||
var ErrSlugTaken = errors.New("slug taken")
|
||||
|
||||
// RenameInstance changes an instance's name and re-derives its slug.
|
||||
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)
|
||||
```
|
||||
|
||||
It lives beside `CreateInstanceWithID` so slug derivation keeps one home, and it
|
||||
behaves as that function's rules imply:
|
||||
|
||||
- `BaseSlug(name)` failures wrap `ErrNameRejected` — too short, too long,
|
||||
reserved.
|
||||
- The derived slug is compared against the instance's current one. If they are
|
||||
equal, only the name is written; a cosmetic capitalisation change is not a
|
||||
move, and must not fail on its own slug.
|
||||
- **No `-2` suffix loop.** Creation appends a counter because the customer is
|
||||
waiting on an instance and any free slug will do. A rename is a request for a
|
||||
specific host, and silently landing the customer on `acme-2` is a worse answer
|
||||
than refusing.
|
||||
- A duplicate-key error on the update surfaces as `ErrSlugTaken`, exactly as the
|
||||
create path treats it as "that slug is taken". The pre-check is a courtesy;
|
||||
the index is the boundary.
|
||||
|
||||
### `admin/internal/cloudprov`
|
||||
|
||||
```go
|
||||
func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)
|
||||
```
|
||||
|
||||
A thin wrapper over `provision.RenameInstance` on `db.ControlDB()`. It writes
|
||||
`instances` and nothing else, so admin's documented control-plane write boundary
|
||||
— `instances` and `users`, from `cloudprov` and `inject` only — is unchanged.
|
||||
|
||||
### `admin/internal/models`
|
||||
|
||||
`Instance` gains:
|
||||
|
||||
```go
|
||||
// RenamedAt is when this instance last changed name, and backs the 24h
|
||||
// customer cooldown. The cooldown is admin's policy, so it lives on admin's
|
||||
// row rather than in the control plane, which has no opinion about how often
|
||||
// a customer may move.
|
||||
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
|
||||
```
|
||||
|
||||
A pointer because absent means "never renamed", and a zero `time.Time` would
|
||||
read as 1 January year 1 — far enough in the past that the cooldown is inert,
|
||||
but only by accident.
|
||||
|
||||
### `PUT /api/instances/:id/name` (customer)
|
||||
|
||||
Mounted in the `cust` group behind `auth.RequireAccountRole(owner, admin)`, and
|
||||
resolving the instance through `ownedInstance` like every other instance route,
|
||||
so another account's instance answers 404 rather than 403.
|
||||
|
||||
Body: `{"name": "..."}`, trimmed before use.
|
||||
|
||||
Refusals, in the order checked:
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| `deployment != cloud` | 400 | `selfHostedRefusal`, the same constant and status the member endpoints already answer with |
|
||||
| `placeholder` | 409 | instance is not provisioned yet |
|
||||
| within 24h of `renamed_at` | 429 | includes the UTC time it unlocks |
|
||||
| `provision.ErrNameRejected` | 422 | the wrapped reason, verbatim |
|
||||
| `provision.ErrSlugTaken` | 409 | that name is already in use |
|
||||
|
||||
Success returns `{"instance_id", "name", "slug", "login_url"}` and writes an
|
||||
audit entry `instance.renamed` with detail `<old-slug> -> <new-slug>`, so the
|
||||
history of a host is answerable from the audit log alone.
|
||||
|
||||
`login_url` comes from the existing `loginURLFor(slug)`, which fills `{slug}`
|
||||
into `APP_LOGIN_URL` — the same builder the licence emails already use, rather
|
||||
than a second opinion about how a tenant host is spelled. It is empty when
|
||||
`APP_LOGIN_URL` is unset, and the portal then falls back to the host string it
|
||||
already composes from the slug in `InstanceRecord` and the instance page.
|
||||
|
||||
### `PUT /api/staff/instances/:id/name`
|
||||
|
||||
The same core, without the cooldown, actor recorded as the staff user. On a
|
||||
self-hosted instance it updates `admin_instances.name` only and does not call
|
||||
`cloudprov`.
|
||||
|
||||
## Frontend (`adminsite`)
|
||||
|
||||
### `lib/slug.ts`
|
||||
|
||||
A TypeScript mirror of `provision.Slugify` and the length/reserved checks, used
|
||||
only to preview the resulting host while the customer types. It carries the same
|
||||
warning as `web/lib/targets.ts`: it is a second implementation and must change in
|
||||
the same commit as the Go one. The preview can disagree with the server — the
|
||||
409 is the answer that counts.
|
||||
|
||||
### `components/RenamePanel.tsx`
|
||||
|
||||
An inline panel, not a modal — `adminsite` has no modal component, and the
|
||||
codebase's idiom for a destructive-ish action with one input is `RelinkPanel`:
|
||||
a control that expands in place inside a `Panel`.
|
||||
|
||||
Prefilled with the current name. Below the input, a live line reading
|
||||
`acme-ltd.vantage.hostxtra.co.uk` as the customer types, and a note that they
|
||||
will need to sign in again on the new host. Submit is disabled while the derived
|
||||
slug is unchanged or invalid.
|
||||
|
||||
It lives in an "Address" panel on `app/(customer)/instances/[id]/page.tsx`,
|
||||
rendered only when the instance is cloud and `account_role` is `owner` or
|
||||
`admin`. The staff instance page mounts the same component against the staff
|
||||
route.
|
||||
|
||||
`InstanceRecord` on the Overview page is not touched: it stays a summary, and
|
||||
the rename is a decision that deserves the detail page.
|
||||
|
||||
### After a successful rename
|
||||
|
||||
Invalidate `["account"]`, collapse the panel, and let the page redraw with the new
|
||||
name and host. The Console rail card shows the new host, with a note:
|
||||
|
||||
> This instance now lives at `acme-ltd.vantage.hostxtra.co.uk`. You will need to
|
||||
> sign in again there.
|
||||
|
||||
**No automatic redirect.** Sending the browser to the new host lands the customer
|
||||
on a login screen with no explanation, having just lost the HQ page they were
|
||||
standing on. The link is right there; they click it when they are ready.
|
||||
|
||||
## Verification
|
||||
|
||||
The repository has no Go test suite, so verification is build plus manual
|
||||
exercise, matching existing practice:
|
||||
|
||||
- `go build ./...` in `shared` and `admin`; `npm run build` in `adminsite`.
|
||||
- Rename a cloud instance; confirm `instances` and `admin_instances` agree on
|
||||
name and slug.
|
||||
- The new host serves a login page; the old host stops resolving to the instance
|
||||
within ~60 seconds.
|
||||
- A second rename within 24 hours answers 429.
|
||||
- A rename onto an occupied slug answers 409 and changes nothing.
|
||||
- A rename attempt on a self-hosted instance from the customer portal answers
|
||||
400, the same status and constant the member endpoints already answer with.
|
||||
- The audit log carries `instance.renamed` with both slugs.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Slug aliases or redirects from the old host. The control plane resolves one
|
||||
slug per instance, and an alias table is a second identity to keep correct for
|
||||
the sake of stale bookmarks.
|
||||
- Renaming from inside the control plane's own `/settings`. HQ owns instance
|
||||
identity, the same way it owns licences and `hq`-sourced users; a second
|
||||
writer would need the same collision handling and the same cooldown.
|
||||
- Any change to the licence, subscription or Paddle line items.
|
||||
@@ -4,64 +4,73 @@ title: Claim a Free licence
|
||||
sidebar_label: Claim a Free licence
|
||||
---
|
||||
|
||||
A self-hosted install runs unlicensed until you give it a licence. Free is a
|
||||
real tier in both deployments, and you can claim one for your install from the
|
||||
HQ portal.
|
||||
A self-hosted install stays read-only until you give it a licence. You claim a
|
||||
Free one from Vantage HQ, and it takes a couple of minutes.
|
||||
|
||||
:::warning An unlicensed install is read-only
|
||||
You can sign in and look around, but adding servers, keys, workflows and
|
||||
everything else is refused until a licence is installed. Do this before
|
||||
[adding your first server](./first-server.md).
|
||||
:::
|
||||
|
||||
## What a licence is
|
||||
|
||||
A signed file. It carries the instance ID it belongs to, the tier, the server
|
||||
allowance, feature toggles and an expiry. The control plane verifies the
|
||||
signature locally.
|
||||
A signed file that names your instance, its tier, how many servers you may
|
||||
manage, which features are enabled and when it expires. Your install checks the
|
||||
signature itself, so it never has to reach Vantage HQ to work.
|
||||
|
||||
A running instance does not need HQ to be reachable.
|
||||
|
||||
:::info One Free per account, per deployment.
|
||||
The limit is enforced per account **and** deployment, so a Free cloud instance does not stop you claiming Free on a self-hosted install.
|
||||
:::info One Free licence per account, per deployment
|
||||
A Free cloud instance does not use up your Free self-hosted one. They are
|
||||
separate.
|
||||
:::
|
||||
|
||||
## 1. Find your instance ID
|
||||
|
||||
In the control plane, go to **Settings → Licence**. The instance ID is shown there.
|
||||
Open the **Licence** page from the sidebar of your install. The instance ID is
|
||||
shown at the top, and it is the value Vantage HQ asks for.
|
||||
|
||||
## 2. Create a free license
|
||||
## 2. Create the instance in Vantage HQ
|
||||
|
||||
1. Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk). If you have no
|
||||
account, see [Accounts and signup](../hq/accounts-and-signup.md).
|
||||
2. Click on the **Buy A Plan** button.
|
||||
3. Click on **Self Hosted** then click on the **Free** plan, then finally Paste the instance ID and give it a name you will recognise.
|
||||
account yet, see [Accounts and signup](../hq/accounts-and-signup.md).
|
||||
2. On **Overview**, choose **License my own install**. Once you already have an
|
||||
instance, the same page offers **Buy a plan** instead.
|
||||
3. Choose **Self-hosted**, then the **Free** plan.
|
||||
4. Paste your instance ID, give the instance a name you will recognise, and
|
||||
click **Create licence**.
|
||||
|
||||
You will then see the new instance on the **Overview** page.
|
||||
The new instance now appears on the **Overview** page.
|
||||
|
||||
## 3. Downloading the free license
|
||||
## 3. Download the licence
|
||||
|
||||
With the instance created go to the **Overview** page and expand the new instance.
|
||||
|
||||
Click on the **View Instance Settings** button. You can then click on the **Download License** or the **Copy to clipboard** button.
|
||||
Expand the new instance on **Overview** and click **View Instance Settings**.
|
||||
From there, use **Download licence** or **Copy to clipboard**.
|
||||
|
||||
## 4. Install the licence
|
||||
|
||||
Download the licence from HQ and paste it in the control plane at
|
||||
**Settings → Licence**.
|
||||
Back in your install, open the **Licence** page, paste the licence and save.
|
||||
Your instance confirms the licence was issued to it, then shows your tier,
|
||||
server allowance and expiry date.
|
||||
|
||||
The instance validates the signature, checks the ID matches its own, and
|
||||
starts reporting the tier, allowance and expiry.
|
||||
|
||||
:::info Cloud instances do **not** require installing the license as this is done automatically.
|
||||
:::info Cloud instances need none of this
|
||||
A cloud instance is licensed automatically when it is created. These steps are
|
||||
for self-hosted installs only.
|
||||
:::
|
||||
|
||||
## Renewing
|
||||
|
||||
Free licences are renewable from HQ within a renewal window near expiry;
|
||||
outside that window you cannot renew early. See [Free tier](../hq/free-tier.md).
|
||||
Free licences run for a year. The renew button appears in Vantage HQ seven days
|
||||
before expiry and stays available after it, so a lapsed instance can still be
|
||||
rescued. See [Free tier](../hq/free-tier.md).
|
||||
|
||||
## Moving the install to new hardware
|
||||
|
||||
Rebuilding produces a new instance ID, and a licence binds to a ID. Use
|
||||
**Relink** in HQ to move the licence across. The number of relinks per term is
|
||||
capped; the portal shows how many you have left.
|
||||
A rebuilt install gets a new instance ID, and a licence only works for the ID it
|
||||
was issued to. Use **Relink** in Vantage HQ to move the licence across. You get
|
||||
three relinks per term, and the portal shows how many are left.
|
||||
|
||||
## Next
|
||||
|
||||
- [Add your first server](./first-server.md)
|
||||
- [Licensing and entitlements](../hq/licensing-and-entitlements.md)
|
||||
- [Buying a paid self-hosted licence](../hq/self-hosted-instances.md)
|
||||
|
||||
@@ -4,64 +4,62 @@ title: Cloud or self-hosted
|
||||
sidebar_label: Cloud or self-hosted
|
||||
---
|
||||
|
||||
Vantage runs in two deployments. They are the same software; what differs is
|
||||
who operates it and how licensing, users and data lifecycle work.
|
||||
Vantage runs in two ways. It is the same software; what differs is who runs it,
|
||||
and how licensing and user accounts work.
|
||||
|
||||
## At a glance
|
||||
|
||||
| | Cloud | Self-hosted |
|
||||
| --------------------- | --------------------------------------------------- | ------------------------------ |
|
||||
| Who runs it | We do | You do |
|
||||
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
|
||||
| Database and backups | Ours | Yours |
|
||||
| Licence | Written for you when you buy or create the instance | Pasted in, or claimed from HQ |
|
||||
| Team members | Granted from HQ; the instance holds a projection | Created in the instance itself |
|
||||
| Free tier | Yes, one per account | Yes, one per account |
|
||||
| Expired Free instance | Eventually deleted, after warning | Never deleted |
|
||||
| | Cloud | Self-hosted |
|
||||
| --------------------- | ------------------------------------------- | ------------------------------ |
|
||||
| Who runs it | We do | You do |
|
||||
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
|
||||
| Database and backups | Ours | Yours |
|
||||
| Licence | Installed for you | You paste it in |
|
||||
| Team members | Granted from Vantage HQ | Created in the instance itself |
|
||||
| Free tier | Yes, one per account | Yes, one per account |
|
||||
| Expired Free instance | Eventually deleted, after warning emails | Never deleted |
|
||||
|
||||
## Cloud
|
||||
|
||||
You create an instance from the HQ portal and it exists a few seconds later,
|
||||
already licensed. People you grant access to get a real user inside that
|
||||
instance see [People and roles](../hq/people-and-roles.md) but HQ owns their
|
||||
password, role and existence.
|
||||
You create an instance from the Vantage HQ portal and it is ready seconds later,
|
||||
already licensed. People you grant access to get a real account inside that
|
||||
instance, but Vantage HQ owns their password and role. See
|
||||
[People and roles](../hq/people-and-roles.md).
|
||||
|
||||
:::info The instance does not phone home
|
||||
A grant writes a user row into the control plane once. After that the instance
|
||||
authenticates that person entirely on its own. HQ being down does not stop
|
||||
anyone signing in to a running instance.
|
||||
:::
|
||||
Your instance keeps working whether or not Vantage HQ is reachable. Signing in
|
||||
and managing servers never depend on it.
|
||||
|
||||
Cloud instances on the Free tier are reaped after their licence expires, with
|
||||
warning emails first. See [Free tier](../hq/free-tier.md).
|
||||
Cloud instances on the Free tier are deleted some time after their licence
|
||||
expires, with warning emails first. See [Free tier](../hq/free-tier.md).
|
||||
|
||||
## Self-hosted
|
||||
|
||||
You run the Docker Compose stack on your own infrastructure. Nothing about the
|
||||
control plane requires an internet connection to HQ at runtime a licence is a
|
||||
signed file, verified locally.
|
||||
You run Vantage with Docker Compose on your own infrastructure. It needs no
|
||||
connection to us at runtime, because a licence is a signed file your install
|
||||
checks for itself.
|
||||
|
||||
Two ways to get one:
|
||||
|
||||
1. **Free** link the install to an HQ account and claim it
|
||||
([Claim a Free licence](./claim-free-licence.md)).
|
||||
2. **Paid** buy from HQ, which creates a placeholder, then paste the install's
|
||||
real instance UUID to bind and issue
|
||||
([Self-hosted instances](../hq/self-hosted-instances.md)).
|
||||
1. **Free.** Link the install to a Vantage HQ account and claim it. See
|
||||
[Claim a Free licence](./claim-free-licence.md).
|
||||
2. **Paid.** Buy from Vantage HQ, then paste your install's instance ID to have
|
||||
the licence issued. See
|
||||
[Self-hosted instances](../hq/self-hosted-instances.md).
|
||||
|
||||
Self-hosted users are local (or OIDC). There is no projection from HQ, and the
|
||||
three member endpoints in HQ refuse to touch a self-hosted instance at all.
|
||||
People who sign in to a self-hosted install are created in the install itself,
|
||||
either with a password or through single sign-on. Vantage HQ cannot add them for
|
||||
you.
|
||||
|
||||
## Which should you pick
|
||||
|
||||
Pick cloud if you want the thing running now and do not want to own a MongoDB.
|
||||
Pick cloud if you want it running today and would rather not run a database.
|
||||
Pick self-hosted if your policy requires the control plane inside your own
|
||||
network, or the servers you manage cannot reach the public internet.
|
||||
network, or the servers you manage cannot reach the internet.
|
||||
|
||||
Moving between them is a migration, not a switch instances are bound to a
|
||||
deployment at creation, and a licence binds to an instance UUID.
|
||||
Moving between the two means migrating your data, and a new licence, since a
|
||||
licence is tied to one instance.
|
||||
|
||||
## Next
|
||||
|
||||
- [Self-hosted install](./self-hosted-install.md)
|
||||
- [Accounts and signup](../hq/accounts-and-signup.md) if you are going cloud
|
||||
- [Accounts and signup](../hq/accounts-and-signup.md), if you are going cloud
|
||||
|
||||
@@ -6,67 +6,82 @@ sidebar_label: First login
|
||||
|
||||
A fresh install has no users and no instance. The first visit creates both.
|
||||
|
||||
## 1. Bootstrap
|
||||
## 1. Create the first account
|
||||
|
||||
Open the control plane in a browser. Because no user exists, you land on
|
||||
`/setup`.
|
||||
Open your Vantage address in a browser. Because no user exists yet, you land on
|
||||
the setup page.
|
||||
|
||||
Fill in:
|
||||
|
||||
| Field | Notes |
|
||||
| ------------- | ------------------------------------- |
|
||||
| Instance name | Display name. Shown throughout the UI |
|
||||
| Email | Becomes your sign-in identity |
|
||||
| Password | Stored bcrypt-hashed |
|
||||
| Field | Notes |
|
||||
| ---------------- | --------------------------------------------------------- |
|
||||
| Instance name | Also used to derive your instance's own subdomain |
|
||||
| Owner email | Becomes your sign-in identity |
|
||||
| Password | At least 8 characters |
|
||||
| Confirm password | Must match |
|
||||
|
||||
Submitting creates the instance and its **owner** you.
|
||||
**Setup Instance** creates the instance and makes you its **owner**.
|
||||
|
||||
:::warning Bootstrap works exactly once
|
||||
The endpoint is open only while the database has no users. As soon as the first
|
||||
one exists, There is no second chance to create the first owner, so record the
|
||||
credentials before you continue.
|
||||
:::warning Your instance gets its own address
|
||||
If you installed on a name like `vantage.example.com`, an instance called Acme
|
||||
signs in at `acme.vantage.example.com`, and each instance keeps its own sign-in.
|
||||
Make sure DNS and your reverse proxy cover that subdomain, or use a wildcard.
|
||||
:::
|
||||
|
||||
## 2. Copy the Instance ID
|
||||
:::warning Setup runs exactly once
|
||||
It is only available while the database has no users. Once yours exists, the
|
||||
page closes for good, so record the email and password before you continue.
|
||||
:::
|
||||
|
||||
Once you have finished setup you will see the successfully created page.
|
||||
## 2. Copy the instance ID
|
||||
|
||||
This will show the Instance ID. You will need this ID when creating a license in the HQ.
|
||||
The confirmation page, headed **Instance created**, shows your instance ID and
|
||||
your sign-in address. You need that ID to claim a licence in Vantage HQ, and it
|
||||
is the reference support works from. You can find it again later on the
|
||||
**Licence** page in the sidebar.
|
||||
|
||||
## 3. Sign in
|
||||
|
||||
Click the continue to sign in button on the successful setup page.
|
||||
|
||||
You will be taken to `/login`. Sign in with the email and password you just set.
|
||||
Click **Go to sign in**, then sign in with the email and password you just set.
|
||||
|
||||
## 4. Look around
|
||||
|
||||
You land on the servers dashboard, which is empty. The sidebar is the whole
|
||||
product:
|
||||
You land on the servers page, which is empty. The sidebar is the whole product:
|
||||
|
||||
| Section | What it does |
|
||||
| --------- | ------------------------------------------ |
|
||||
| Servers | The server enrol, inspect, console, update |
|
||||
| Keys | SSH public keys and their assignments |
|
||||
| Workflows | Compose and run scripted work |
|
||||
| Steps | The reusable step library |
|
||||
| Monitors | HTTP, TCP, ICMP and TLS checks |
|
||||
| Secrets | The encrypted vault |
|
||||
| Audit | Every mutating action |
|
||||
| Settings | Members, SSO, alerts, retention, licence |
|
||||
| Section | What it does |
|
||||
| --------------- | ------------------------------------------------ |
|
||||
| Servers | Your fleet: enrol, inspect, console, update |
|
||||
| Monitors | HTTP, TCP, ping and certificate checks |
|
||||
| Vulnerabilities | Known security issues in installed packages |
|
||||
| Workloads | Containers and services running on your servers |
|
||||
| SSH Keys | Public keys and which servers they are on |
|
||||
| Secrets | The encrypted vault |
|
||||
| Workflows | Compose and run scripted work |
|
||||
| Steps | The reusable step library |
|
||||
| Audit Log | A record of everything that changed |
|
||||
| Licence | Your tier, allowance and expiry |
|
||||
| Settings | People, sign-in, alerts and integrations |
|
||||
|
||||
## 5. Add the rest of your team
|
||||
**Licence** and **Settings** are shown only to owners and admins.
|
||||
|
||||
Go to **Settings → Access**. Add members with a role:
|
||||
## 5. Install your licence
|
||||
|
||||
| Role | Can |
|
||||
| -------- | -------------------------------------------------- |
|
||||
| `owner` | Everything, including billing-adjacent settings |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| `member` | Day-to-day work servers, keys, workflows, monitors |
|
||||
Until a licence is installed, the instance is read-only: you can look, but you
|
||||
cannot add servers or anything else. Continue with
|
||||
[Claim a Free licence](./claim-free-licence.md).
|
||||
|
||||
Settings and organisation management require `owner` or `admin`.
|
||||
## 6. Add the rest of your team
|
||||
|
||||
If you would rather not manage passwords, configure single sign-on instead: see [Settings](../vantage/settings.md#single-sign-on).
|
||||
Go to **Settings → Access** and add people with a role:
|
||||
|
||||
You can add more than one identity provider; each gets its own button on the login page, and no buttons appear at all until at least one provider is configured.
|
||||
| Role | Can |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| `owner` | Everything |
|
||||
| `admin` | Everything except owner-only settings |
|
||||
| `member` | Day-to-day work: servers, keys, workflows, monitors |
|
||||
|
||||
Changing settings, and adding or removing people, needs `owner` or `admin`.
|
||||
|
||||
If you would rather not manage passwords, you can use single sign-on instead,
|
||||
which is available on paid plans. See
|
||||
[Settings](../vantage/settings.md#single-sign-on).
|
||||
|
||||
@@ -4,64 +4,67 @@ title: Add your first server
|
||||
sidebar_label: Add your first server
|
||||
---
|
||||
|
||||
Enrolling a server means running one command on it. The control plane issues a
|
||||
short-lived token, the install script fetches the agent and writes a config, and
|
||||
the machine registers itself.
|
||||
Enrolling a server means running one command on it. Vantage issues a short-lived
|
||||
token, the install script fetches the agent and writes a config file, and the
|
||||
machine registers itself.
|
||||
|
||||
:::info You need a licence first
|
||||
An unlicensed install is read-only, so **Add server** will be refused until a
|
||||
licence is in place. If you have not done that yet, start with
|
||||
[Claim a Free licence](./claim-free-licence.md).
|
||||
:::
|
||||
|
||||
## 1. Create the enrolment
|
||||
|
||||
In the UI, go to **Servers → Add server** Then click the **Generate Install Command** button.
|
||||
This generates a server ID and a pre-registration token
|
||||
Go to **Servers → Add server**, then click **Generate install command**. Vantage
|
||||
creates a server record and an enrolment token for it.
|
||||
|
||||
:::warning The token is single-use and lives one hour
|
||||
It is the only credential in the flow, and it is spent the moment the agent registers.
|
||||
:::warning The token is single-use and lasts one hour
|
||||
It is the only credential in the flow, and it is spent the moment the agent
|
||||
registers. If it expires, generate a new command rather than reusing the old one.
|
||||
:::
|
||||
|
||||
## 2. Run the one-liner
|
||||
|
||||
### Linux
|
||||
|
||||
Run the generated install script as root.
|
||||
|
||||
Here is an example of the install script:
|
||||
Run the generated command as root. It looks like this:
|
||||
|
||||
```bash
|
||||
curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" | bash
|
||||
```
|
||||
|
||||
What the script does:
|
||||
The script:
|
||||
|
||||
1. Detects architecture `x86_64` and `aarch64` only; anything else exits.
|
||||
2. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**, aborting on a mismatch.
|
||||
3. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
|
||||
4. Writes the config file at `/etc/vantage/config.yaml`
|
||||
1. This contains the server ID, the pre-registration token and the gRPC host.
|
||||
5. Writes the systemd service file `/etc/systemd/system/vantage-agent.service` and starts the agent.
|
||||
1. Checks the architecture. Only `x86_64` and `aarch64` are supported.
|
||||
2. Downloads the agent and verifies its SHA-256 checksum, stopping on a mismatch.
|
||||
3. Installs the agent to `/usr/local/bin/vantage-agent`.
|
||||
4. Writes `/etc/vantage/config.yaml` with the server ID, the enrolment token and
|
||||
the address the agent connects to.
|
||||
5. Installs and starts the `vantage-agent` systemd service.
|
||||
|
||||
### Windows
|
||||
|
||||
Run this from an elevated PowerShell prompt:
|
||||
|
||||
```powershell
|
||||
irm "https://vantage.example.com/install.ps1?server_id=<id>&token=<token>" | iex
|
||||
```
|
||||
|
||||
Run from an elevated PowerShell.
|
||||
It writes the config to `%ProgramData%\vantage\config.yaml`, installs the agent
|
||||
as a Windows service and starts it.
|
||||
|
||||
What the script does:
|
||||
|
||||
1. Creates the config at `%ProgramData%\vantage\config.yaml`.
|
||||
1. This contains the server ID, the pre-registration token and the gRPC host.
|
||||
2. Downloads the agent MSI from Gitea.
|
||||
3. Installs the MSI and creates the Windows service.
|
||||
4. Starts the agent.
|
||||
|
||||
:::info Windows agents do **not** manage `authorized_keys` as this is a Linux-only function.
|
||||
:::info Windows servers do not get SSH key management
|
||||
Windows agents register, report inventory and run workflow steps. Managing
|
||||
`authorized_keys` is a Linux-only feature.
|
||||
:::
|
||||
|
||||
## 3. Watch it come up
|
||||
|
||||
The server appears immediately as `pending`. Within one poll interval, 30 seconds it becomes `active`.
|
||||
The server appears as `pending` straight away, and becomes `active` within about
|
||||
30 seconds.
|
||||
|
||||
Check the systemd logs using the following commands:
|
||||
On Linux you can watch the agent itself:
|
||||
|
||||
```bash
|
||||
systemctl status vantage-agent
|
||||
@@ -70,28 +73,30 @@ journalctl -u vantage-agent -f
|
||||
|
||||
## 4. Confirm it works
|
||||
|
||||
Open the server's detail page. Within a minute or two you should see:
|
||||
Open the server's page. Within a minute or two you should see:
|
||||
|
||||
- Status `active`, with a recent last-seen timestamp.
|
||||
- Inventory CPU, memory, swap, partitions, kernel. Metrics refresh every 30
|
||||
seconds; the full static snapshot every 15 minutes.
|
||||
- Pending OS updates, checked hourly.
|
||||
- Status `active`, with a recent last-seen time.
|
||||
- Inventory: CPU, memory, swap, partitions and kernel. Metrics refresh every 30
|
||||
seconds, and the fuller snapshot every 15 minutes.
|
||||
- Any pending OS updates, which the agent checks for hourly.
|
||||
|
||||
## If it does not appear
|
||||
|
||||
| Symptom | Cause |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Script exits at "Unsupported architecture" | Not amd64 or arm64 |
|
||||
| "Checksum mismatch!" | Interrupted download, or a proxy rewriting the body. Re-run |
|
||||
| "Could not determine latest agent version" | The host cannot reach `gitea.hostxtra.co.uk`, or no `agent/v*` release exists |
|
||||
| Service runs, server stays `pending` | The machine cannot reach `GRPC_HOST`. Test it from that machine |
|
||||
| Registers once then goes `offline` | Reachable for `Register` but not for the poll usually a firewall that permits the initial connection but drops the long-lived one |
|
||||
| Symptom | What to check |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| Script stops at "Unsupported architecture" | The machine is not 64-bit x86 or ARM |
|
||||
| "Checksum mismatch!" | An interrupted download, or a proxy rewriting the response. Run it again |
|
||||
| "Could not determine latest agent version" | The machine cannot reach the release host, or `GITEA_HOST` is not set on your control plane |
|
||||
| Service runs, server stays `pending` | The machine cannot reach the agent port. Test it from that machine, not from the control plane |
|
||||
| Registers, then goes `offline` | A firewall or proxy allows the first connection but drops the long-lived one |
|
||||
| "Server limit reached" | Your licence allowance is full. Raise it in Vantage HQ, or remove a server you no longer manage |
|
||||
|
||||
A server is marked `offline` when its last-seen time passes the threshold; that
|
||||
sweep runs every two minutes, so allow for it before concluding anything.
|
||||
A server is marked `offline` once it has not been seen for a while, and that
|
||||
check runs every couple of minutes, so give it a moment before concluding
|
||||
anything.
|
||||
|
||||
## Next Steps
|
||||
## Next steps
|
||||
|
||||
- [Assign an SSH key](../vantage/ssh-keys.md)
|
||||
- [Run a workflow](../vantage/workflows.md)
|
||||
- [Claim a Free licence](./claim-free-licence.md)
|
||||
- [Watch something with a monitor](../vantage/monitors.md)
|
||||
|
||||
@@ -4,52 +4,46 @@ title: Install Vantage (self-hosted)
|
||||
sidebar_label: Self-hosted install
|
||||
---
|
||||
|
||||
This installs the control plane on a host you own. Budget about fifteen minutes.
|
||||
This puts the control plane on a host you own. Budget about fifteen minutes.
|
||||
|
||||
## Before you start
|
||||
|
||||
You need:
|
||||
|
||||
- A Linux host with **Docker** and the **Compose plugin**.
|
||||
- A DNS name pointing at it. You will use it for both the web UI and, with a
|
||||
port, for agents.
|
||||
- A reverse proxy terminating TLS in front of the web UI. gRPC on `:9090` is
|
||||
reached directly by agents.
|
||||
- Two ports reachable from every machine you intend to manage: the web port for
|
||||
people, and **9090** for agents.
|
||||
- Outbound access from the control plane, and from every managed machine, to
|
||||
`gitea.hostxtra.co.uk`, which serves the agent releases.
|
||||
- A DNS name pointing at that host. People use it for the web UI, and your
|
||||
agents use it too.
|
||||
- A reverse proxy in front of Vantage that terminates TLS. It needs to handle
|
||||
both the web UI and the agent port, `9090`, which speaks HTTP/2.
|
||||
- Those two ports reachable: the web port from wherever your people are, and
|
||||
`9090` from every machine you intend to manage.
|
||||
- Outbound access from the control plane, and from each managed machine, to
|
||||
`gitea.hostxtra.co.uk`, which serves the agent downloads.
|
||||
|
||||
The stack itself brings MongoDB, Redis and guacd with it. You do not need to
|
||||
provide a database.
|
||||
The stack brings MongoDB, Redis and the console daemon with it, so there is no
|
||||
database to provide.
|
||||
|
||||
## 1. Get the compose file
|
||||
|
||||
Put `deploy/docker-compose.yml` from the repository in a working directory, for
|
||||
example `/opt/vantage`.
|
||||
## 1. Get the Compose file
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/vantage/data && cd /opt/vantage
|
||||
# copy docker-compose.yml here
|
||||
mkdir -p /opt/vantage && cd /opt/vantage
|
||||
curl -fsSLO https://gitea.hostxtra.co.uk/mrhid6/vantage/raw/branch/main/deploy/docker/docker-compose.yml
|
||||
```
|
||||
|
||||
The `server` service bind-mounts `./data`, which is where workflow run logs are
|
||||
written. Create it before first boot so it is not owned by root-in-container in
|
||||
a way you did not intend.
|
||||
|
||||
## 2. Write the environment file
|
||||
|
||||
Create `/opt/vantage/.env`:
|
||||
|
||||
```bash
|
||||
# The host:port agents dial. NOT the web URL this port speaks gRPC.
|
||||
# The host:port your agents connect to. This is not the web URL;
|
||||
# this port speaks gRPC.
|
||||
GRPC_HOST=vantage.example.com:9090
|
||||
|
||||
|
||||
# 32 bytes as 64 hex characters. Generate with the command below.
|
||||
# 32 bytes as 64 hex characters. Generate it with the command below.
|
||||
KEY_ENCRYPTION_KEY=
|
||||
|
||||
# Optional: where workflow run logs are written inside the container.
|
||||
# The host serving agent downloads.
|
||||
GITEA_HOST=gitea.hostxtra.co.uk
|
||||
```
|
||||
|
||||
Generate the encryption key:
|
||||
@@ -58,17 +52,27 @@ Generate the encryption key:
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
:::danger Keep the encryption key
|
||||
`KEY_ENCRYPTION_KEY` encrypts SSH private keys, vault secrets, OIDC client
|
||||
secrets and console credentials with AES-256-GCM. Lose it and every one of those
|
||||
becomes unreadable there is no recovery path. Back it up somewhere other than
|
||||
the server it protects, and never rotate it without a planned re-encryption.
|
||||
Then make sure the `server` service passes `GITEA_HOST` through, by adding this
|
||||
line to its `environment:` block in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
GITEA_HOST: ${GITEA_HOST}
|
||||
```
|
||||
|
||||
Without it, the install command you hand to a new server cannot work out which
|
||||
agent to download.
|
||||
|
||||
:::danger Keep the encryption key safe
|
||||
`KEY_ENCRYPTION_KEY` encrypts SSH private keys, vault secrets, single sign-on
|
||||
client secrets and console credentials. If you lose it, all of those become
|
||||
unreadable and there is no way to recover them. Back it up somewhere other than
|
||||
the server it protects, and do not change it once the install is in use.
|
||||
:::
|
||||
|
||||
:::warning `GRPC_HOST` has no default
|
||||
The server refuses to boot without it. There is deliberately no fallback to the
|
||||
web host: that would hand every agent a port that does not speak gRPC, and the
|
||||
failure would only surface later, on each agent, as a connection error.
|
||||
The server will not start without it. There is deliberately no fallback to your
|
||||
web address, because that port does not speak the protocol agents use, and the
|
||||
mistake would only show up later as every agent failing to connect.
|
||||
:::
|
||||
|
||||
## 3. Start the stack
|
||||
@@ -78,31 +82,37 @@ docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Five services come up: `mongo`, `redis`, `guacd`, `server` and `web`.
|
||||
Five services start: `mongo`, `redis`, `guacd`, `server` and `web`.
|
||||
|
||||
Check the server got through boot:
|
||||
Check the server got through startup:
|
||||
|
||||
```bash
|
||||
docker compose logs -f server
|
||||
```
|
||||
|
||||
Boot runs database migrations, builds indexes and seeds the default workflow
|
||||
step library. Index builders for auth and settings are **fatal on failure** —
|
||||
they enforce tenant isolation, so the server would rather not start than start
|
||||
without them.
|
||||
On first boot it prepares the database and loads the built-in workflow step
|
||||
library. If it stops during that, it will say why, and it is meant to stop
|
||||
rather than run in a half-prepared state.
|
||||
|
||||
## 4. Put a proxy in front
|
||||
|
||||
Point your reverse proxy at `web` on port `3000` and terminate TLS there. The
|
||||
web app calls the REST API through a Next rewrite, so you do not need to expose
|
||||
`8080` publicly.
|
||||
web app reaches the API internally, so there is no need to publish port `8080`.
|
||||
|
||||
Do **not** proxy `9090`. Agents connect to it directly over TLS.
|
||||
Agents connect to port `9090`. Vantage does not terminate TLS itself, so put
|
||||
that port behind your proxy too, with a certificate valid for the name in
|
||||
`GRPC_HOST`. The proxy must speak HTTP/2 through to Vantage. Many do not do so
|
||||
by default, and the symptom is agents that register once and then stop
|
||||
responding.
|
||||
|
||||
For a private network where TLS is not required, you can instead set
|
||||
`tls: false` in each [agent's config](../reference/agent-config.md) and let
|
||||
agents reach the port directly.
|
||||
|
||||
## 5. First sign-in
|
||||
|
||||
Open your hostname in a browser. With no users in the database, you are sent to
|
||||
`/setup`.
|
||||
Open your hostname in a browser. With no users in the database yet, you are sent
|
||||
to the setup page.
|
||||
|
||||
Continue with [First login](./first-login.md).
|
||||
|
||||
@@ -113,23 +123,24 @@ Continue with [First login](./first-login.md).
|
||||
| `docker compose ps` | five services `running` |
|
||||
| `curl -s localhost:8080/auth/bootstrap-status` | JSON saying bootstrap is needed |
|
||||
| `nc -z your-host 9090` | open |
|
||||
| `docker compose logs server \| grep -i fatal` | nothing |
|
||||
| `docker compose logs server` | no fatal errors |
|
||||
|
||||
## Common install problems
|
||||
|
||||
**Server exits immediately.** Almost always a missing `GRPC_HOST`. The log line
|
||||
**The server exits immediately.** Almost always a missing `GRPC_HOST`. The log
|
||||
names it.
|
||||
|
||||
**Agents register but never go active.** They reached `:9090` for `Register` but
|
||||
cannot sustain the poll, or `GRPC_HOST` names a host they resolve differently.
|
||||
Check from the managed machine, not from the control plane host.
|
||||
**Agents register but never go active.** They reached port `9090` once but
|
||||
cannot hold the connection, or your proxy is not passing HTTP/2 through. Test
|
||||
from the managed machine, not from the control plane host.
|
||||
|
||||
**Secrets pages error.** `KEY_ENCRYPTION_KEY` is empty or not 64 hex characters.
|
||||
**Secrets pages show an error.** `KEY_ENCRYPTION_KEY` is empty or is not 64 hex
|
||||
characters.
|
||||
|
||||
More in [Troubleshooting](../reference/troubleshooting.md).
|
||||
|
||||
## What this install does not include
|
||||
## What is not included
|
||||
|
||||
The website, the HQ portal and this documentation site are hosted by us and are
|
||||
not part of a self-hosted install. It deliberately runs none of them, and in
|
||||
particular never holds the licence signing key.
|
||||
The marketing site, the Vantage HQ portal and this documentation site are hosted
|
||||
by us. A self-hosted install runs none of them, and it never holds the key that
|
||||
signs licences.
|
||||
|
||||
@@ -5,59 +5,52 @@ sidebar_label: What is Vantage
|
||||
---
|
||||
|
||||
Vantage manages a fleet of servers from one place. It began as SSH key
|
||||
management and grew outwards: key assignment, scripted workflow execution,
|
||||
service monitoring, a secrets vault, a browser-based console and OS update
|
||||
management.
|
||||
management and grew outwards: key assignment, scripted workflows, service
|
||||
monitoring, a secrets vault, a browser console and OS update management.
|
||||
|
||||
## The pieces
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
W["Web UI<br/>servers · keys · workflows · monitors<br/>secrets · audit · console · settings"]
|
||||
S["Server<br/>REST :8080 · gRPC :9090<br/>MongoDB · Redis · guacd"]
|
||||
S["Vantage server<br/>the control plane"]
|
||||
A["Agent<br/>one per managed server<br/>Linux and Windows"]
|
||||
W -->|REST, cookie session| S
|
||||
S -->|gRPC over TLS| A
|
||||
A -.->|outbound only| S
|
||||
W -->|you sign in here| S
|
||||
S -->|sends work| A
|
||||
A -.->|connects outbound| S
|
||||
```
|
||||
|
||||
**The server** holds all state and does all decision-making. It exposes a REST
|
||||
API on `:8080` for the web UI and a gRPC API on `:9090` for agents. MongoDB
|
||||
stores everything durable; Redis stores sessions and nothing else.
|
||||
**The server** is the control plane. It holds all your data and makes all the
|
||||
decisions.
|
||||
|
||||
**The agent** is a single Go binary running as root on each managed server. It
|
||||
polls the control plane every 30 seconds for desired key state, and holds a
|
||||
bidirectional command stream so the server can push work run a workflow step,
|
||||
generate a key, apply updates without waiting for the next poll.
|
||||
**The agent** is a single small program running on each managed server. It asks
|
||||
the control plane what it should be doing, and holds an open connection so
|
||||
Vantage can send it work without waiting.
|
||||
|
||||
**The web UI** is the operator interface. Everything it does goes through the
|
||||
REST API, which is the actual security boundary; the UI only ever makes things
|
||||
convenient.
|
||||
**The web UI** is what you use. Everything it can do goes through the same API
|
||||
that enforces your permissions, so nothing is possible in the UI that would not
|
||||
be permitted elsewhere.
|
||||
|
||||
## How agents connect
|
||||
|
||||
The agent dials **out** to the control plane. There is no inbound listener on a
|
||||
managed server, no port to open and no NAT traversal to arrange. If the machine
|
||||
can reach your Vantage host on the gRPC port, it can be managed.
|
||||
The agent always connects **outbound**. There is no listener on a managed
|
||||
server, no port to open and no NAT to work around. If the machine can reach your
|
||||
Vantage address, it can be managed.
|
||||
|
||||
That direction is why `GRPC_HOST` exists as an explicit setting: the agent has
|
||||
to be told a `host:port` it can reach, and there is no safe default the server
|
||||
could guess on its behalf.
|
||||
That is why you tell Vantage its own agent address (`GRPC_HOST`) when you install
|
||||
it: the agent has to be given an address it can reach, and Vantage cannot guess
|
||||
one for you.
|
||||
|
||||
## Two request patterns
|
||||
## Keeping things current
|
||||
|
||||
| Pattern | Used for | Why |
|
||||
| ----------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| Poll, every 30s | desired SSH key state | Key changes are not urgent, and polling survives a dropped connection with no reconnection logic |
|
||||
| Push, over the command stream | workflow steps, key generation, updates, agent self-update | Clicking Run should not wait up to 30 seconds |
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
Every document in the database carries an instance ID, and every query is scoped
|
||||
by it. One deployment can therefore host many independent tenants. On a
|
||||
self-hosted install that mechanism is still there you simply have one tenant.
|
||||
| What | How it works |
|
||||
| ------------------------- | ------------------------------------------------------------------------- |
|
||||
| SSH keys on a server | The agent checks every 30 seconds and only writes when something changed |
|
||||
| Workflow steps, updates | Sent to the agent straight away, so clicking Run does not wait for a check |
|
||||
| Inventory | Reported every 30 seconds, with a fuller snapshot every 15 minutes |
|
||||
| Pending OS updates | Checked hourly |
|
||||
|
||||
## Next
|
||||
|
||||
- [Cloud or self-hosted](./cloud-vs-self-hosted.md) which one you want
|
||||
- [Self-hosted install](./self-hosted-install.md) stand it up
|
||||
- [Cloud or self-hosted](./cloud-vs-self-hosted.md), to pick which one you want
|
||||
- [Self-hosted install](./self-hosted-install.md), to stand it up
|
||||
|
||||
@@ -20,9 +20,9 @@ role:
|
||||
Reading is open to any signed-in member. Every mutation except changing your own
|
||||
password requires `owner` or `admin`. Billing is owner-only.
|
||||
|
||||
The three words are the same as the control plane's roles, on purpose but they
|
||||
are separate things. Your account role governs the portal; your role _inside_ an
|
||||
instance governs that instance.
|
||||
These are the same three words your instances use, but they are separate things.
|
||||
Your account role controls what you can do in the portal. Your role inside an
|
||||
instance controls what you can do there.
|
||||
|
||||
## Signing up
|
||||
|
||||
@@ -38,14 +38,19 @@ Verification links are valid for **24 hours**.
|
||||
|
||||
## Signing in
|
||||
|
||||
Use the Email and password used in the signup form to login to the HQ, signing in to HQ does not sign you in to an instance, and vice versa.
|
||||
Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk) with the email and
|
||||
password from the signup form.
|
||||
|
||||
HQ and your Vantage instances have separate sessions: signing in to HQ does not
|
||||
sign you in to an instance, and signing in to an instance does not sign you in
|
||||
to HQ.
|
||||
|
||||
## What comes next
|
||||
|
||||
| You want | Go to |
|
||||
| ------------------------------ | ------------------------------------------------------------- |
|
||||
| A Vantage instance we run | [Cloud instances](./cloud-instances.md) |
|
||||
| To license an install you run | [Self-hosted instances](./self-hosted-instances.md) |
|
||||
| To licence an install you run | [Self-hosted instances](./self-hosted-instances.md) |
|
||||
| To add colleagues | [People and roles](./people-and-roles.md) |
|
||||
| To understand tiers and limits | [Licensing and entitlements](./licensing-and-entitlements.md) |
|
||||
|
||||
@@ -55,4 +60,4 @@ Three destinations: **Overview**, **People**, **Billing**.
|
||||
|
||||
- Overview lists your instances.
|
||||
- People shows all the account members and their roles.
|
||||
- Billing show the current subscriptions and subscription management.
|
||||
- Billing shows your current subscriptions and lets you manage them.
|
||||
|
||||
+17
-11
@@ -12,25 +12,31 @@ customer reference and nothing sensitive.
|
||||
The Billing page requires the **owner-only** account role.
|
||||
:::
|
||||
|
||||
## Buying A Plan
|
||||
## Buying a plan
|
||||
|
||||
Buying a plan license can be found in Vantage HQ by clicking on the **Buy a Plan** button on the **Overview** page.
|
||||
In Vantage HQ, click **Buy a plan** on the **Overview** page.
|
||||
|
||||
### Cloud
|
||||
|
||||
On the **Buy A Plan** page you will need to select the **Deployment** to **Cloud** then chose your **Billing** cycle (Monthly or Annually).
|
||||
1. Set **Deployment** to **Cloud**.
|
||||
2. Choose a **Billing** cycle, monthly or annual.
|
||||
3. Choose a **Plan** and configure its features and server allowance.
|
||||
4. Enter an **Instance name** and click **Continue to payment**.
|
||||
|
||||
Then select your desired **Plan** and configure the features.
|
||||
|
||||
Finally specify the **Instance Name** and click the **Continue to payment** button.
|
||||
The instance is created and licensed as soon as payment confirms.
|
||||
|
||||
### Self-hosted
|
||||
|
||||
On the **Buy A Plan** page you will need to select the **Deployment** to **Self-Hosted** then chose your **Billing** cycle (Monthly or Annually).
|
||||
Install your control plane first — a licence binds to its instance ID.
|
||||
|
||||
Then select your desired **Plan** and configure the features.
|
||||
1. Set **Deployment** to **Self-hosted**.
|
||||
2. Choose a **Plan** and configure its features and server allowance.
|
||||
3. Under **Your install**, paste the instance ID from your install's **Licence**
|
||||
page. An ID you already hold is upgraded in place.
|
||||
4. Enter an **Instance name** and click **Continue to payment**.
|
||||
|
||||
Finally specify the **Instance Name** and click the **Continue to payment** button.
|
||||
The licence is issued as soon as payment confirms; download it and paste it into
|
||||
your install. See [Self-hosted instances](./self-hosted-instances.md).
|
||||
|
||||
## Cancelling and failed payments
|
||||
|
||||
@@ -38,7 +44,7 @@ Cancelling, or a payment going past due, takes **no immediate licence action**.
|
||||
Your licence runs to its grace-padded expiry and then lapses normally. There is
|
||||
no mid-term cut-off.
|
||||
|
||||
For a cloud Free instance, lapsing eventually leads to deletion see
|
||||
For a cloud Free instance, lapsing eventually leads to deletion. See
|
||||
[Free tier](./free-tier.md). Paid instances are not deleted.
|
||||
|
||||
## Renewals
|
||||
@@ -47,4 +53,4 @@ At renewal the subscription bills again and the licence is reissued for the new
|
||||
term. It is also the only moment a scheduled **reduction** takes effect.
|
||||
|
||||
- Self-hosted customers: download and paste the reissued licence.
|
||||
- Cloud customers: the license is automatically linked to the instance.
|
||||
- Cloud customers: nothing to do. The licence is written to the instance for you.
|
||||
|
||||
@@ -9,18 +9,19 @@ A cloud instance is a Vantage control plane we run for you, reachable at
|
||||
|
||||
## Creating one
|
||||
|
||||
1. **Overview → New instance**.
|
||||
2. Choose a name and a slug.
|
||||
3. Create.
|
||||
1. On **Overview**, choose **Create a cloud instance**. Once you already have an
|
||||
instance, the same page offers **Buy a plan** instead.
|
||||
2. Choose **Cloud**, then the plan you want.
|
||||
3. Give the instance a name and confirm.
|
||||
|
||||
The instance is provisioned with you as its owner, and a Free licence is issued
|
||||
immediately. The owner user inside it gets your HQ password hash **copied**, not
|
||||
shared see [People and roles](./people-and-roles.md).
|
||||
immediately. Your Vantage HQ password gets you into it, though the two are kept
|
||||
in step rather than shared. See [People and roles](./people-and-roles.md).
|
||||
|
||||
### Slugs
|
||||
|
||||
The slug becomes your hostname label, so it is lowercase, and some names are
|
||||
reserved. Pick something you can say on a phone call.
|
||||
Your instance's address comes from the name you choose, lowercased, with some
|
||||
names reserved. Pick something short that you can say on a phone call.
|
||||
|
||||
:::warning One Free instance per account, per deployment
|
||||
Creating a second Free cloud instance is refused. If you want another, it needs
|
||||
@@ -41,9 +42,9 @@ affect anyone signing in or any agent syncing.
|
||||
|
||||
Each instance on Overview is one record. Closed, it is a row. Open, it shows:
|
||||
|
||||
- **Licence contents** tier, server allowance, features, expiry.
|
||||
- **Members** who has access and with what instance role.
|
||||
- **Actions** grant access, change configuration, renew.
|
||||
- **Licence contents**: tier, server allowance, features and expiry.
|
||||
- **Members**: who has access, and with what role.
|
||||
- **Actions**: grant access, change configuration, renew.
|
||||
|
||||
## Members
|
||||
|
||||
@@ -53,14 +54,15 @@ Granting access writes a real user into the instance. Covered fully in
|
||||
## Changing what it can do
|
||||
|
||||
Server allowance and per-instance features (browser console, single sign-on) are
|
||||
part of the instance's **entitlement**. Changing it goes through billing see
|
||||
part of the instance's **entitlement**, the configuration your licence is cut
|
||||
from. Changing it goes through billing. See
|
||||
[Licensing and entitlements](./licensing-and-entitlements.md) and
|
||||
[Billing](./billing.md).
|
||||
|
||||
## Renaming
|
||||
|
||||
The display name is free to change. The slug is the hostname and is not
|
||||
casually changed ask support if you need it.
|
||||
The display name is free to change. The slug is part of your hostname, so ask
|
||||
support if you need that changed.
|
||||
|
||||
## What happens if the licence lapses
|
||||
|
||||
@@ -73,6 +75,4 @@ runs to its grace-padded expiry and then lapses.
|
||||
|
||||
## Deleting
|
||||
|
||||
Ask support. Deletion is performed by the control plane, not by HQ the control
|
||||
plane is the only service that knows which collections carry the instance ID,
|
||||
and duplicating that list into HQ would be a list that drifts.
|
||||
Ask support.
|
||||
|
||||
@@ -4,7 +4,8 @@ title: Free tier
|
||||
sidebar_label: Free tier
|
||||
---
|
||||
|
||||
Free is a real tier in both deployments not a trial that turns into nothing.
|
||||
Free is a permanent tier. It is available whether we host Vantage for you or you
|
||||
host it yourself.
|
||||
|
||||
## What you get
|
||||
|
||||
@@ -23,16 +24,15 @@ features on a paid plan.
|
||||
## One per account, per deployment
|
||||
|
||||
The limit is enforced per account **and** deployment. A Free cloud instance does
|
||||
not prevent a Free self-hosted one they are separate slots.
|
||||
not prevent a Free self-hosted one; they are separate slots.
|
||||
|
||||
## Renewing
|
||||
|
||||
Free licences have a term and must be renewed from the portal.
|
||||
Free licences run for a year and are renewed from the portal.
|
||||
|
||||
- The renew button appears **7 days before expiry**.
|
||||
- It stays available **after** expiry, right up until the instance is reaped —
|
||||
so the same button rescues a lapsed instance rather than needing a second
|
||||
mechanism.
|
||||
- It stays available **after** expiry, right up until a lapsed cloud instance is
|
||||
deleted, so the same button rescues one.
|
||||
- Renewing outside that window is refused, and the message names the date it
|
||||
opens.
|
||||
|
||||
@@ -55,19 +55,13 @@ There is no restore. If a cloud Free instance is approaching that date and you
|
||||
want to keep it, renew it, or move it to a paid plan.
|
||||
:::
|
||||
|
||||
Deletion is carried out by the control plane rather than by HQ. HQ sends the
|
||||
warnings because it knows the billing address; the control plane performs the
|
||||
delete because it is the only service that knows which collections carry the
|
||||
instance ID.
|
||||
|
||||
## Moving off Free
|
||||
|
||||
Change the instance's configuration to a paid tier and check out. Your data
|
||||
stays where it is a tier change reissues a licence, it does not rebuild
|
||||
stays where it is: a tier change reissues a licence, it does not rebuild
|
||||
anything.
|
||||
|
||||
## Relinks
|
||||
|
||||
Free instances get the same relink allowance as paid ones: three per term. That
|
||||
cap exists to put a human in front of a fourth attempt, not to obstruct a
|
||||
genuine rebuild.
|
||||
Free instances get the same allowance as paid ones: three relinks per term. If
|
||||
you genuinely need more, ask support.
|
||||
|
||||
@@ -10,7 +10,7 @@ A **licence** is a signed statement of what one instance may do. An
|
||||
## Tiers
|
||||
|
||||
Three tiers, in both deployments. The allowances are identical across cloud and
|
||||
self-hosted what differs is the term on offer, not what you get.
|
||||
self-hosted; what differs is the term on offer, not what you get.
|
||||
|
||||
| | Free | Professional | Enterprise |
|
||||
| --------------------- | --------- | ------------ | --------------------- |
|
||||
@@ -23,17 +23,18 @@ self-hosted what differs is the term on offer, not what you get.
|
||||
|
||||
The server count is **metered**: the base allowance comes with the tier, and you
|
||||
buy additional servers on top. That is why Professional shows a real number
|
||||
rather than "unlimited" the number you actually have is the one in your
|
||||
rather than "unlimited": the number you actually have is the one in your
|
||||
entitlement.
|
||||
|
||||
## Features
|
||||
|
||||
Two are per-instance toggles rather than tier bundles:
|
||||
Three features are enabled per instance rather than bundled into a tier:
|
||||
|
||||
| Feature | What it enables |
|
||||
| --------- | -------------------------------------------------------------------- |
|
||||
| `console` | The [browser console](../vantage/browser-console.md) |
|
||||
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on) |
|
||||
| Browser console | The [browser console](../vantage/browser-console.md) |
|
||||
| Single sign-on | [Sign-in through your identity provider](../vantage/settings.md#single-sign-on) |
|
||||
| Vulnerability scanning| [Package vulnerability scanning](../vantage/vulnerabilities.md) |
|
||||
|
||||
No tier includes them by default; you enable them on the instances that need
|
||||
them.
|
||||
@@ -62,26 +63,35 @@ happens at renewal.
|
||||
|
||||
## What a licence carries
|
||||
|
||||
Instance UUID, deployment, tier, resolved limits, features, term and expiry —
|
||||
all signed.
|
||||
Your instance ID, whether it is cloud or self-hosted, the tier, your limits,
|
||||
which features are enabled, and when it expires. All of it is signed.
|
||||
|
||||
Two properties follow from that:
|
||||
|
||||
- **A licence is bound to one instance UUID.** Moving it takes a
|
||||
[relink](./self-hosted-instances.md#relinking).
|
||||
- **A licence is a snapshot.** Editing a plan later never rewrites an issued
|
||||
licence, the same way editing a workflow step never rewrites a past run.
|
||||
- **A licence works for one instance only.** Moving it to a rebuilt install
|
||||
takes a [relink](./self-hosted-instances.md#relinking).
|
||||
- **A licence is a snapshot.** Changing a plan later does not rewrite a licence
|
||||
already issued.
|
||||
|
||||
Verification is local. Your instance does not call HQ to check a licence, and
|
||||
signing happens only in HQ.
|
||||
|
||||
## Expiry and grace
|
||||
## Expiry, grace and degraded mode
|
||||
|
||||
Expiry is padded with a grace period. Past that, the instance goes into degraded
|
||||
mode: it keeps running and keeps your data, but stops letting you do everything.
|
||||
Expiry is padded with a few days' grace. Past that, an instance goes into
|
||||
**degraded mode**, which means:
|
||||
|
||||
The way out is a current licence renew or purchase, then paste it (self-hosted)
|
||||
or let it be written for you (cloud).
|
||||
- It keeps running, and all of your data stays exactly where it is.
|
||||
- You can still sign in and read everything.
|
||||
- Adding or changing anything is refused.
|
||||
- Deleting things still works, so you can get back under a reduced allowance.
|
||||
- Applying OS updates still works, because security patching is never blocked.
|
||||
|
||||
A brand-new self-hosted install behaves the same way until you install its first
|
||||
licence.
|
||||
|
||||
The way out is a current licence: renew or purchase, then paste it
|
||||
(self-hosted) or let it be written for you (cloud).
|
||||
|
||||
## Server limits in practice
|
||||
|
||||
|
||||
@@ -26,14 +26,11 @@ Owners and admins invite; billing is owner-only.
|
||||
3. They receive a link and set their own password at `/accept-invite`.
|
||||
|
||||
:::info Why you cannot set their password
|
||||
An invitation creates a person with an **empty password hash**, which cannot
|
||||
authenticate at all until they set one. If the inviter chose it, that password
|
||||
would be a shared credential to every instance the person is later granted
|
||||
access to.
|
||||
An invited person cannot sign in at all until they set their own password. If
|
||||
you chose it for them, it would be a shared password to every instance they are
|
||||
later given access to.
|
||||
|
||||
The verification endpoint knows the difference: a token belonging to a
|
||||
passwordless person reports that a password is needed and is left unspent, so
|
||||
the link still works when they get to it.
|
||||
Their invitation link stays valid until they use it to set that password.
|
||||
:::
|
||||
|
||||
### Removing someone
|
||||
@@ -43,26 +40,26 @@ happens to their instance access.
|
||||
|
||||
## Instance access
|
||||
|
||||
Granting access to a **cloud** instance creates a real user inside that
|
||||
instance's control plane, with `auth_source: "hq"`.
|
||||
Granting access to a **cloud** instance creates a real account inside that
|
||||
instance, marked as managed by Vantage HQ.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["HQ account member"] -->|grant| U["Control-plane user<br/>auth_source: hq"]
|
||||
U --> I["The instance authenticates<br/>this user like any other"]
|
||||
P["Person in your Vantage HQ account"] -->|you grant access| U["Account inside the instance"]
|
||||
U --> I["They sign in at the instance,<br/>like anyone else"]
|
||||
```
|
||||
|
||||
The instance authenticates that user exactly as it authenticates anyone else,
|
||||
with **no runtime dependency on HQ**. Revoking deletes the row the control
|
||||
plane has no disabled state, and a row that exists is a row that can sign in.
|
||||
They then sign in at the instance itself, and that keeps working whether or not
|
||||
Vantage HQ is reachable. Revoking removes the account outright, so access ends
|
||||
immediately.
|
||||
|
||||
### Granting
|
||||
|
||||
On an instance record, **Members → Add**, choose an account member and an
|
||||
instance role (`owner`, `admin`, `member`).
|
||||
|
||||
One person holds at most one user per instance, so granting twice is refused
|
||||
rather than quietly creating a second user.
|
||||
One person gets one account per instance, so granting twice is refused rather
|
||||
than quietly creating a second.
|
||||
|
||||
### Roles inside an instance
|
||||
|
||||
@@ -71,28 +68,24 @@ instance `owner`, or the reverse.
|
||||
|
||||
### Revoking
|
||||
|
||||
Removes the user from the instance immediately. Any live session ends with the
|
||||
session, since the user row backing it is gone.
|
||||
Removes their access immediately, and ends any session they have open.
|
||||
|
||||
:::warning Self-hosted instances cannot be granted from HQ
|
||||
All three member endpoints refuse when the instance is self-hosted. Manage those
|
||||
users in the instance itself, at **Settings → Access**.
|
||||
Vantage HQ cannot add or remove people in a self-hosted install. Manage them in
|
||||
the install itself, at **Settings → Access**.
|
||||
:::
|
||||
|
||||
## Passwords
|
||||
|
||||
Your HQ password is the single source of truth for every user projected from it.
|
||||
Changing it in the portal rehashes it and copies the hash to every instance you
|
||||
have been granted.
|
||||
One Vantage HQ password covers you and every cloud instance you have been given
|
||||
access to. Change it in the portal and it changes everywhere, within about 15
|
||||
minutes at worst if an instance is briefly unreachable.
|
||||
|
||||
Propagation is best-effort and immediate; a background pass compares and repairs
|
||||
every 15 minutes, so a temporarily unreachable instance catches up on its own.
|
||||
|
||||
There is no local password-change endpoint for those users in the control plane,
|
||||
so there is never a second writer for the hash.
|
||||
Those people cannot change that password inside an instance, so there is only
|
||||
ever one place it is set.
|
||||
|
||||
:::warning HQ-managed users are read-only in the instance
|
||||
Changing the role of, or deleting, an `hq`-sourced user inside the control plane
|
||||
is refused with `409`. Do it from the portal. The UI shows those rows read-only
|
||||
with a link back here, but the API is the boundary; the UI is the courtesy.
|
||||
Changing the role of, or removing, someone managed by Vantage HQ has to be done
|
||||
from the portal. Inside the instance those rows are read-only, with a link back
|
||||
here.
|
||||
:::
|
||||
|
||||
@@ -14,59 +14,56 @@ Install first, then link and claim. Step by step in
|
||||
|
||||
## Paid
|
||||
|
||||
Buying happens **before** the install has to exist, because you may well be
|
||||
buying in order to build it.
|
||||
**Install first.** A licence is issued to one instance, so your control plane
|
||||
has to exist and report an instance ID before you can buy for it — the same
|
||||
precondition Free has.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Buy in HQ"] --> B["Placeholder instance<br/>awaiting_link, no licence"]
|
||||
B --> C["Install Vantage<br/>get its instance UUID"]
|
||||
C --> D["Paste the UUID<br/>claim-link"]
|
||||
D --> E["Licence issued<br/>bound to that UUID"]
|
||||
A["Install Vantage<br/>find its instance ID"] --> B["Buy in Vantage HQ<br/>paste that ID"]
|
||||
B --> C["Payment confirms"]
|
||||
C --> D["Licence issued<br/>for that instance"]
|
||||
```
|
||||
|
||||
1. **Overview → Buy self-hosted**, choose tier, term and configuration.
|
||||
2. Complete checkout. HQ creates a **placeholder** instance in state
|
||||
`awaiting_link` with no licence attached.
|
||||
3. [Install Vantage](../getting-started/self-hosted-install.md) if you have not
|
||||
already, and find its instance UUID at **Settings → Licence**.
|
||||
4. Back in HQ, open the placeholder and paste the UUID.
|
||||
5. The licence is issued, bound to that UUID. Download it and paste it into your
|
||||
install.
|
||||
1. [Install Vantage](../getting-started/self-hosted-install.md) and find its
|
||||
instance ID on the **Licence** page.
|
||||
2. On **Overview**, choose **Buy a plan**. Pick **Self-hosted**, then your tier
|
||||
and configuration.
|
||||
3. Under **Your install**, paste the instance ID. Enter an **Instance name** and
|
||||
click **Continue to payment**.
|
||||
4. The licence is issued as soon as payment confirms. Download it and paste it
|
||||
into your install.
|
||||
|
||||
:::info Why there is a placeholder at all
|
||||
A licence binds to an instance UUID, and at the moment of payment that UUID may
|
||||
not exist yet. Issuing early would mean issuing to nothing; refusing to sell
|
||||
until you had installed would be the wrong order. The placeholder holds the
|
||||
purchase until there is something to bind to.
|
||||
:::
|
||||
## Upgrading an instance you already have
|
||||
|
||||
## Linking an existing install
|
||||
Paste the same instance ID you already hold — an install on Free moves to the
|
||||
paid plan in place, keeping its ID and its history. An ID belonging to another
|
||||
account is refused.
|
||||
|
||||
If the install already exists, **Link an instance** takes the UUID directly. A
|
||||
UUID already claimed by another account is refused with a conflict.
|
||||
For a Free licence, see
|
||||
[Claim a Free licence](../getting-started/claim-free-licence.md).
|
||||
|
||||
## Relinking
|
||||
|
||||
Rebuilding the host produces a new instance UUID, and the old licence no longer
|
||||
matches. **Relink** moves the licence to the new UUID and reissues.
|
||||
Rebuilding the host gives you a new instance ID, which your old licence does not
|
||||
match. **Relink** moves the licence across and reissues it.
|
||||
|
||||
The number of relinks per term is capped, and the portal shows how many you have
|
||||
left. This is not meant to obstruct disaster recovery if you have exhausted
|
||||
them for a real reason, ask support.
|
||||
left. If you have used them all for a genuine reason, ask support.
|
||||
|
||||
## Installing the licence
|
||||
|
||||
Paste it at **Settings → Licence** in your install. The instance verifies the
|
||||
signature and checks that the UUID matches its own.
|
||||
Paste it on the **Licence** page in your install. It confirms the licence was
|
||||
issued to that instance before applying it.
|
||||
|
||||
Pasting works even while the current licence is expired that endpoint is
|
||||
exempt from the licence check, because it is the route out of degraded mode.
|
||||
Pasting works even while your current licence has expired, because that is how
|
||||
you get out of degraded mode.
|
||||
|
||||
## Keeping it current
|
||||
|
||||
Your install does not fetch licences. When a licence is reissued renewal,
|
||||
configuration change, relink download the new one from HQ and paste it in.
|
||||
Your install never downloads a licence by itself. Whenever one is reissued, on
|
||||
renewal, on a configuration change or after a relink, download it from Vantage
|
||||
HQ and paste it in.
|
||||
|
||||
:::warning Nothing reminds your install
|
||||
The control plane knows only what its licence says. Expiry emails come from HQ,
|
||||
|
||||
@@ -7,9 +7,9 @@ slug: /
|
||||
|
||||
# Vantage documentation
|
||||
|
||||
Vantage is a self-hosted, multi-tenant infrastructure control plane. It manages
|
||||
SSH keys, runs scripted workflows, watches services, stores secrets, opens
|
||||
browser consoles and applies OS updates across a fleet of servers.
|
||||
Vantage manages a fleet of servers from one place: SSH keys, scripted workflows,
|
||||
service monitoring, a secrets vault, browser consoles and OS updates. Run it
|
||||
yourself, or let us run it for you.
|
||||
|
||||
A central server drives a lightweight agent installed on each managed machine.
|
||||
The agent connects **outbound only**, so managed servers need no inbound
|
||||
@@ -20,14 +20,16 @@ firewall holes.
|
||||
| If you want to | Read |
|
||||
| --------------------------------------- | --------------------------------------------------------------- |
|
||||
| Understand what the pieces are | [What is Vantage](./getting-started/what-is-vantage.md) |
|
||||
| Decide who should run it | [Cloud or self-hosted](./getting-started/cloud-vs-self-hosted.md) |
|
||||
| Run it on your own hardware | [Self-hosted install](./getting-started/self-hosted-install.md) |
|
||||
| Licence a self-hosted install | [Claim a Free licence](./getting-started/claim-free-licence.md) |
|
||||
| Enrol your first machine | [Add your first server](./getting-started/first-server.md) |
|
||||
| Manage your account, licence or billing | [Vantage HQ](./hq/accounts-and-signup.md) |
|
||||
| Look something up | [Reference](./reference/environment-variables.md) |
|
||||
|
||||
## The two products
|
||||
|
||||
**Vantage** is the control plane the thing you sign in to in order to manage
|
||||
**Vantage** is the control plane, the thing you sign in to in order to manage
|
||||
servers. It runs either on your own infrastructure or as a cloud instance we
|
||||
run for you.
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ Each server's detail page shows the version it reported at its last sync.
|
||||
|
||||
## Updating from the UI
|
||||
|
||||
**Servers → _a server_ → Update agent** pushes `UpdateAgentCmd` with a target
|
||||
version. The agent then:
|
||||
Open a server and choose **Update agent**. The agent then:
|
||||
|
||||
1. Downloads the binary for its platform from the release.
|
||||
2. Verifies the SHA-256 against `checksums.txt`.
|
||||
@@ -35,8 +34,8 @@ irm https://vantage.example.com/update.ps1 | iex
|
||||
```
|
||||
|
||||
It does the same download, checksum and replace, then restarts the service. Use
|
||||
this when the control plane cannot push for example, when the machine is
|
||||
reachable but its command stream is not.
|
||||
this when Vantage cannot reach the agent to push the update, but you can still
|
||||
reach the machine.
|
||||
|
||||
## Rolling out across a fleet
|
||||
|
||||
@@ -54,9 +53,8 @@ Do one, confirm it returns to `active`, then do the rest.
|
||||
|
||||
## Version compatibility
|
||||
|
||||
The agent API is versioned to tolerate an agent older than the control plane. The
|
||||
reverse an agent newer than the control plane is not a case anyone tests.
|
||||
Upgrade the control plane first.
|
||||
An agent older than your control plane is supported. An agent newer than it is
|
||||
not, so upgrade the control plane first.
|
||||
|
||||
Agents report their version on every poll, so a fleet running mixed
|
||||
versions is visible in the server list rather than something you have to go
|
||||
|
||||
@@ -11,9 +11,8 @@ either one restores to something unusable.
|
||||
|
||||
| Store | Contents | Back up |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| MongoDB | Everything durable servers, keys, assignments, workflows, runs, monitors, incidents, secrets, settings, audit | **Yes** |
|
||||
| MongoDB | Everything durable: servers, keys, assignments, workflows, runs and their logs, monitors, incidents, secrets, settings, audit | **Yes** |
|
||||
| Redis | Sessions only | No. Losing it signs everyone out and nothing else |
|
||||
| `./data` bind mount | Workflow run logs | Optional |
|
||||
| `KEY_ENCRYPTION_KEY` | Not stored anywhere by the app | **Yes, separately** |
|
||||
|
||||
:::danger The database alone is not a backup
|
||||
@@ -50,7 +49,7 @@ nothing writes during the restore.
|
||||
cp /opt/vantage/.env /secure-location/vantage.env
|
||||
```
|
||||
|
||||
Treat it as a credential in its own right it holds the encryption key.
|
||||
Treat it as a credential in its own right, since it holds the encryption key.
|
||||
|
||||
## What a restore gives you
|
||||
|
||||
@@ -62,7 +61,7 @@ What it does **not** do is reconcile the world. After a restore:
|
||||
- Agents reconnect with their existing tokens, since the token hashes are in the
|
||||
database.
|
||||
- If the restore is older than an enrolment, that server's token hash is missing
|
||||
and the agent will fail to authenticate re-enrol it.
|
||||
and the agent will fail to authenticate. Re-enrol it.
|
||||
- The next agent poll rewrites `authorized_keys` to match the restored desired
|
||||
state, which may remove keys added since the backup.
|
||||
|
||||
@@ -74,8 +73,8 @@ What it does **not** do is reconcile the world. After a restore:
|
||||
| Environment file | On change, held in a password manager or secret store |
|
||||
| Restore rehearsal | Occasionally, into a throwaway host |
|
||||
|
||||
The rehearsal is the part that gets skipped and the part that finds the
|
||||
problems.
|
||||
Rehearse a restore now and again. It is the step most often skipped, and the one
|
||||
that finds the problems.
|
||||
|
||||
## Cloud instances
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ sidebar_label: Upgrading
|
||||
---
|
||||
|
||||
Upgrading the control plane is a pull and a recreate. Agents are versioned and
|
||||
upgraded separately see [Agent updates](./agent-updates.md).
|
||||
upgraded separately. See [Agent updates](./agent-updates.md).
|
||||
|
||||
:::info Cloud instances upgrade themselves
|
||||
This page is for self-hosted installs. If your instance is hosted by us, there
|
||||
@@ -26,11 +26,11 @@ renamed or removed.
|
||||
|
||||
## What happens on boot
|
||||
|
||||
1. **Migrations** run, recording markers so each runs once.
|
||||
2. **Indexes** are ensured. Auth and settings index builders are fatal on
|
||||
failure; secret and workflow ones only warn.
|
||||
3. **Default steps** are reseeded from the image, overwriting the `default`
|
||||
library which is why those steps are read-only.
|
||||
1. The database is brought up to date. Each change runs once.
|
||||
2. The built-in workflow steps are reinstalled, which is why those steps cannot
|
||||
be edited.
|
||||
|
||||
If Vantage cannot complete either safely, it stops rather than run half-prepared.
|
||||
|
||||
Watch it:
|
||||
|
||||
@@ -46,6 +46,16 @@ docker compose logs -f server
|
||||
- **Check your `.env`** still supplies everything required. A newly required
|
||||
variable stops the boot rather than defaulting to something unsafe.
|
||||
|
||||
## Single sign-on after an upgrade
|
||||
|
||||
Each identity provider now has its own callback URL. If you configured single
|
||||
sign-on on an older version it was carried over, but its callback URL changed,
|
||||
and sign-in through it fails until you copy the new one from its card in
|
||||
**Settings** and register it with your identity provider. The card shows a
|
||||
reminder until you dismiss it.
|
||||
|
||||
Password sign-in is unaffected, so you can always sign in locally to fix this.
|
||||
|
||||
## Downgrading
|
||||
|
||||
There is no automatic downgrade. Migrations do not roll back, so returning to an
|
||||
@@ -57,7 +67,7 @@ is the reason the backup is not optional.
|
||||
The stack is not designed for it. `docker compose up -d` recreates the server
|
||||
container, which is a short interruption:
|
||||
|
||||
- Agents reconnect on their own they retry, and the poll loop is idempotent.
|
||||
- Agents reconnect on their own.
|
||||
- Workflow runs in progress lose their command stream. Steps already dispatched
|
||||
finish on the agent, but their results have nowhere to go. **Do not upgrade
|
||||
during a run.**
|
||||
@@ -67,5 +77,4 @@ container, which is a short interruption:
|
||||
|
||||
- Confirm every service is `running`.
|
||||
- Confirm servers return to `active` within a couple of poll intervals.
|
||||
- Open a page that touches encryption a secret group to confirm
|
||||
`KEY_ENCRYPTION_KEY` came through.
|
||||
- Open a secret group, to confirm `KEY_ENCRYPTION_KEY` came through.
|
||||
|
||||
@@ -20,15 +20,15 @@ Directory `0700`, file `0600`. The install script sets both.
|
||||
```yaml
|
||||
server_url: "vantage.yourdomain.com:9090"
|
||||
server_id: "<uuid>"
|
||||
pre_reg_token: "<token>" # removed after the first successful Register()
|
||||
agent_token: "" # written by the agent after Register()
|
||||
pre_reg_token: "<token>" # cleared once the agent has registered
|
||||
agent_token: "" # written by the agent when it registers
|
||||
poll_interval: 30s
|
||||
tls: true
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --------------- | --------------------------------------------------------------------- |
|
||||
| `server_url` | `host:port` of the gRPC endpoint. Comes from the server's `GRPC_HOST` |
|
||||
| `server_url` | The `host:port` the agent connects to. Comes from your `GRPC_HOST` |
|
||||
| `server_id` | The identity issued when the enrolment was created |
|
||||
| `pre_reg_token` | Single-use, one hour. Cleared once registration succeeds |
|
||||
| `agent_token` | The permanent credential, written by the agent itself |
|
||||
@@ -36,8 +36,8 @@ tls: true
|
||||
| `tls` | Whether to use TLS. Leave `true` |
|
||||
|
||||
:::danger This file is the credential
|
||||
`agent_token` is plaintext here and nowhere else the control plane holds only
|
||||
its SHA-256. Anyone who can read this file can act as this agent.
|
||||
`agent_token` exists in full only in this file. Anyone who can read it can act
|
||||
as this agent.
|
||||
:::
|
||||
|
||||
## Service management
|
||||
@@ -77,5 +77,5 @@ rm -rf /etc/vantage
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
Keys already written to `authorized_keys` remain on disk the agent is no
|
||||
longer running to remove them. Revoke first if that matters.
|
||||
Keys already written to `authorized_keys` remain on disk, because the agent is
|
||||
no longer running to remove them. Revoke first if that matters.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
id: api-tokens
|
||||
title: API tokens
|
||||
sidebar_label: API tokens
|
||||
---
|
||||
|
||||
A session cookie is fine for a browser. A script, a CI job or a cron task
|
||||
needs something it can hold onto instead — an API token.
|
||||
|
||||
## Creating one
|
||||
|
||||
**API Keys**, in the Access group of the sidebar. The page is reachable at
|
||||
every role: any member may create and revoke their own keys, and owner and
|
||||
admin additionally see every key in the instance. Give it a name, a role
|
||||
(owner, admin or member) and one or more scopes, and optionally an expiry. The value is shown
|
||||
once, in full, immediately after creation:
|
||||
|
||||
```
|
||||
vt_8f2c1a9e4b6d0735a1c8e29f4b0d6e17...
|
||||
```
|
||||
|
||||
That is the only time you will see it. Vantage stores a hash of the token,
|
||||
never the value itself, so if you lose it there is no support ticket that gets
|
||||
it back — create a new token and revoke the old one.
|
||||
|
||||
## Scopes
|
||||
|
||||
A token can reach only what its scopes name. There are eight resources, each
|
||||
with a `:read` and a `:write` scope, and holding `:write` on a resource also
|
||||
satisfies a `:read` requirement for it — you do not need to tick both.
|
||||
|
||||
| Resource | Covers |
|
||||
| ----------- | --------------------------------------------------- |
|
||||
| `servers` | Fleet list, server detail, agent commands, tags |
|
||||
| `keys` | SSH key library and assignment |
|
||||
| `secrets` | The vault |
|
||||
| `workflows` | Steps, workflows, runs and their logs |
|
||||
| `monitors` | Monitors, incidents, uptime and notification channels |
|
||||
| `vulns` | Vulnerability findings, packages and scan rules |
|
||||
| `workloads` | Containers and systemd units, including control actions and logs |
|
||||
| `settings` | Instance settings, members, single sign-on, licence, and token management itself |
|
||||
|
||||
A token created with only `servers:read` can list and inspect servers but
|
||||
cannot run a workflow against them, touch a key, or read a secret — each of
|
||||
those needs its own scope.
|
||||
|
||||
## A token never outranks its owner
|
||||
|
||||
A token's role can be at most the role of the person who created it, and its
|
||||
effective role is **recomputed on every request** as the lower of the two —
|
||||
not fixed at creation. Demote the person from owner to member and every token
|
||||
they hold drops to member from that request onward. Remove the person and
|
||||
every token they hold stops working immediately: a token has no existence
|
||||
independent of its owner.
|
||||
|
||||
## Expiry
|
||||
|
||||
An expiry is optional on a token you create. An instance can set a
|
||||
**maximum key lifetime** (Settings → Integrations) that caps how far out a new
|
||||
token's expiry may be set; when that cap is in place, a token with no expiry
|
||||
at all is refused, so there is no way to route around the policy by leaving
|
||||
the field blank.
|
||||
|
||||
Changing the maximum lifetime only affects tokens created afterwards. It does
|
||||
not shorten, extend or invalidate a token that already exists.
|
||||
|
||||
## Using a token
|
||||
|
||||
Send it as a bearer token:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer vt_…" https://acme.vantage.example.com/api/servers
|
||||
```
|
||||
|
||||
Everything else about the [REST API](./rest-api.md) applies the same way it
|
||||
does to a session — JSON errors, audit logging, licence gating on writes —
|
||||
except that authority comes from the token's role and scopes rather than a
|
||||
signed-in person's role.
|
||||
|
||||
## Rate limit
|
||||
|
||||
A token is limited to **600 requests per minute**. Going over it gets a `429`
|
||||
with a `Retry-After` header naming how many seconds to wait. Cookie sessions
|
||||
are not subject to this limit; it exists so a runaway script cannot take an
|
||||
instance down, not as a general throttle.
|
||||
|
||||
## Rotating a token
|
||||
|
||||
1. Create the replacement token first, with the scopes and role you need.
|
||||
2. Deploy it wherever the old one was used, and confirm it works.
|
||||
3. Revoke the old one.
|
||||
|
||||
Doing it in that order means there is no gap where the credential in use has
|
||||
already been deleted.
|
||||
|
||||
## The full reference
|
||||
|
||||
This page covers the token model. Every route, request and response shape is
|
||||
in the generated OpenAPI reference, served by **your own instance** at
|
||||
`/api/docs` — not this documentation site, since the routes and their shapes
|
||||
are specific to your install. The raw document is at `/api/openapi.json`.
|
||||
|
||||
:::danger Not the External Secrets token
|
||||
The bearer token read by `GET /api/secrets/:group/values` for the Kubernetes
|
||||
External Secrets Operator is a **separate credential** — a single instance-wide
|
||||
value, rotated from Settings, that reaches only that one endpoint. It is not an
|
||||
API token and an API token cannot be used in its place: the two are checked by
|
||||
different code, and neither substitutes for the other. See
|
||||
[Secrets](../vantage/secrets.md#kubernetes-external-secrets-operator).
|
||||
:::
|
||||
@@ -13,16 +13,18 @@ it is absent.
|
||||
| -------------------------- | --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GRPC_HOST` | **yes** | | The `host:port` agents dial. Boot fails without it. There is deliberately no fallback to the web host: that would hand every agent a port that does not speak gRPC |
|
||||
| `MONGO_URI` | no | `mongodb://localhost:27017` | The database name is taken from the URI path, falling back to `vantage`. There is no separate `MONGO_DB` |
|
||||
| `REDIS_ADDR` | no | `localhost:6379` | Sessions, and the bus that routes agent commands between server replicas. Every replica must point at the **same** Redis |
|
||||
| `REDIS_ADDR` | no | `localhost:6379` | Where sessions are held. If you run more than one copy of Vantage, they must all point at the same Redis |
|
||||
| `REDIS_USERNAME` | no | | Redis 6+ ACL user. Leave empty against a legacy `requirepass` instance, which authenticates with the password alone |
|
||||
| `REDIS_PASSWORD` | no | | Leave empty for an unauthenticated Redis. Both of these exist so an install can use a managed Redis rather than the bundled one |
|
||||
| `KEY_ENCRYPTION_KEY` | yes in practice | | 64 hex characters (32 bytes) for AES-256-GCM. Required for private keys, vault secrets, OIDC client secrets and console credentials |
|
||||
| `GITEA_HOST` | yes | `gitea.example.com` | Used to build the install scripts and agent download URLs. The default is a placeholder that will not resolve |
|
||||
| `GITEA_HOST` | yes in practice | `gitea.example.com` | Host serving agent releases; used to build the install scripts and download URLs. The default is a placeholder that will not resolve, so set it to `gitea.hostxtra.co.uk` |
|
||||
| `GUACD_ADDR` | no | `guacd:4822` | The [browser console](../vantage/browser-console.md) daemon |
|
||||
| `PROXY_ADVERTISE_HOST` | no | `server` | The hostname **guacd** uses to reach the control plane's console relay. Wrong here and every console session fails at connect with guacd unable to resolve the relay |
|
||||
| `PROXY_LISTEN_HOST` | no | `0.0.0.0` | Interface the ephemeral relay listeners bind. Narrow it only if guacd shares a known interface |
|
||||
| `POD_IP` | no | | Kubernetes only, set by the Helm chart from the downward API. Overrides `PROXY_ADVERTISE_HOST`, because a console relay belongs to one replica and a Service address names all of them |
|
||||
| `APP_ROOT_LABEL` | no | `vantage` | The app root label for the host and session organisation guard |
|
||||
| `APP_ROOT_LABEL` | no | `vantage` | The label Vantage expects in its own hostname, used to match a browser session to the right instance |
|
||||
| `VANTAGE_LICENSE` | no | | A licence supplied at startup, so an automated install does not have to paste one in |
|
||||
| `VANTAGE_TRIVY_DB_REF` | no | `ghcr.io/aquasecurity/trivy-db:2` | Where the vulnerability database is pulled from. Point it at a mirror for an air-gapped install |
|
||||
| `VANTAGE_VULNDB_DISABLED` | no | | `true` switches [vulnerability scanning](../vantage/vulnerabilities.md) off entirely. Findings already stored are still served, and still shown as stale |
|
||||
|
||||
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
|
||||
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
|
||||
@@ -38,8 +40,8 @@ protecting anything.
|
||||
### Not configurable
|
||||
|
||||
The HTTP port (`8080`) and the gRPC port (`9090`) are fixed in the server. The
|
||||
`HTTP_PORT` and `GRPC_PORT` entries in the shipped Compose file are inert —
|
||||
remap with Docker's port publishing instead.
|
||||
`HTTP_PORT` and `GRPC_PORT` entries in the shipped Compose file have no effect.
|
||||
Remap the ports with Docker instead.
|
||||
|
||||
## Agent
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ sidebar_label: Ports and networking
|
||||
| Port | Service | Who connects | Expose publicly |
|
||||
| ------- | ----------- | -------------------------------- | --------------- |
|
||||
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
|
||||
| `8080` | server REST | The web app | No |
|
||||
| `8080` | server API | The web app | No, firewall it |
|
||||
| `9090` | server gRPC | Agents | **Yes** |
|
||||
| `4822` | guacd | The server | No |
|
||||
| `4822` | guacd | The server | No, firewall it |
|
||||
| `27017` | MongoDB | The server | No |
|
||||
| `6379` | Redis | The server | No |
|
||||
|
||||
@@ -35,11 +35,10 @@ NAT is not an obstacle. The only requirement is that the machine can reach
|
||||
`GRPC_HOST`.
|
||||
|
||||
**The console rides the agent's connection too.** guacd never dials the target
|
||||
directly; the server pushes a command down the agent's existing outbound gRPC
|
||||
stream on `9090`, and the agent relays the protocol traffic from its own
|
||||
loopback. No route from the control plane to the target's address is needed,
|
||||
and no new inbound port opens on the target — the same connection that carries
|
||||
key sync carries console traffic. This is what makes the console work for a
|
||||
directly. Vantage sends the request down the connection the agent already holds
|
||||
on port `9090`, and the agent connects to the service locally on that machine. No route from the control plane to the target's address is needed,
|
||||
and no new inbound port opens on the target. The same connection that keeps keys
|
||||
in sync carries console traffic, which is what makes the console work for a
|
||||
machine behind NAT on a private subnet, as long as its agent is online.
|
||||
|
||||
## What to open
|
||||
@@ -55,8 +54,8 @@ machine behind NAT on a private subnet, as long as its agent is online.
|
||||
- Anything a server-run [monitor](../vantage/monitors.md) checks.
|
||||
- SMTP, if you use an SMTP notification channel.
|
||||
|
||||
No route to the machines you intend to console is needed — that traffic rides
|
||||
the agent's existing outbound `9090` connection instead.
|
||||
No route to the machines you intend to console is needed. That traffic uses the
|
||||
connection the agent already holds.
|
||||
|
||||
### Outbound from a managed machine
|
||||
|
||||
@@ -68,14 +67,17 @@ the agent's existing outbound `9090` connection instead.
|
||||
|
||||
Terminate TLS for the web UI at your reverse proxy.
|
||||
|
||||
gRPC on `9090` is reached directly by agents with `tls: true`, so that port needs
|
||||
a valid certificate for the name in `GRPC_HOST`. If you proxy it, the proxy must
|
||||
speak HTTP/2 end to end many do not by default, and the symptom is agents that
|
||||
register and then fail to hold the command stream.
|
||||
Vantage does not terminate TLS itself, so port `9090` needs the same treatment:
|
||||
put it behind your proxy with a certificate valid for the name in `GRPC_HOST`.
|
||||
The proxy has to pass HTTP/2 through to Vantage. Many do not do that by default,
|
||||
and the symptom is agents that register once and then stop responding.
|
||||
|
||||
On a private network you can skip TLS instead, by setting `tls: false` in each
|
||||
[agent's config](./agent-config.md).
|
||||
|
||||
## Reverse proxy notes
|
||||
|
||||
- Point the proxy at `web:3000`. The web app reaches the REST API internally, so
|
||||
- Point the proxy at `web:3000`. The web app reaches the API internally, so
|
||||
`8080` does not need publishing.
|
||||
- The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does
|
||||
not forward upgrade headers breaks the console and nothing else.
|
||||
@@ -85,8 +87,8 @@ register and then fail to hold the command stream.
|
||||
## Air-gapped and restricted networks
|
||||
|
||||
The control plane needs outbound access to fetch agent releases. Managed
|
||||
machines need it too, unless you distribute the agent binary yourself and write
|
||||
the config by hand the install script's only job is to do those two things.
|
||||
machines need it too, unless you distribute the agent yourself and write its
|
||||
config by hand, which is all the install script does.
|
||||
|
||||
Licence verification is entirely local, so a licensed install works with no
|
||||
outbound access to HQ at all.
|
||||
|
||||
@@ -1,154 +1,59 @@
|
||||
---
|
||||
id: rest-api
|
||||
title: REST API
|
||||
sidebar_label: REST API
|
||||
title: Automating Vantage
|
||||
sidebar_label: Automating Vantage
|
||||
---
|
||||
|
||||
The control plane's HTTP API, on port `8080`. The web UI is a client of it and
|
||||
has no privileges it does not.
|
||||
Everything the web UI does, it does through Vantage's own API, so anything you
|
||||
can do on screen you can also do from a script.
|
||||
|
||||
## Authentication
|
||||
The routes mirror the product: `/api/servers`, `/api/keys`, `/api/workflows`,
|
||||
`/api/monitors`, `/api/secrets`, `/api/audit`, and so on.
|
||||
|
||||
Most endpoints take a session: an opaque 32-byte token in the `km_session`
|
||||
cookie, with the body in Redis for 24 hours.
|
||||
## Where it is
|
||||
|
||||
One endpoint takes a bearer token instead the External Secrets Operator read
|
||||
path.
|
||||
On a self-hosted install the API is served on port `8080`, behind the same
|
||||
reverse proxy as the web UI, under `/api` and `/auth`. On a cloud instance it is
|
||||
your instance hostname.
|
||||
|
||||
## Unauthenticated
|
||||
## Signing in
|
||||
|
||||
```
|
||||
GET /install /install.ps1 # dynamic agent install scripts
|
||||
GET /update /update.ps1
|
||||
GET /auth/bootstrap-status
|
||||
POST /auth/bootstrap /auth/login /auth/logout
|
||||
GET /auth/me
|
||||
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} — no issuer, client ID or secret
|
||||
GET /auth/oidc/:providerId/start · /auth/oidc/:providerId/callback
|
||||
GET /api/secrets/:group/values # bearer token (ESO)
|
||||
Most calls use a session, exactly as the browser does:
|
||||
|
||||
```bash
|
||||
curl -c cookies.txt -X POST https://vantage.example.com/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"you@example.com","password":"..."}'
|
||||
|
||||
curl -b cookies.txt https://vantage.example.com/api/servers
|
||||
```
|
||||
|
||||
`/install` and `/install.ps1` take `server_id` and `token` as query parameters
|
||||
and return a shell script with the newest agent version substituted in.
|
||||
Sessions last 24 hours. Your role applies exactly as it does in the UI: a
|
||||
`member` calling an owner-only route is refused.
|
||||
|
||||
`POST /auth/bootstrap` works only while the database has no users.
|
||||
## The one exception
|
||||
|
||||
## Session-authenticated, under `/api`
|
||||
Kubernetes reads secret groups with a token instead of a session, so that it
|
||||
does not need an account. See
|
||||
[Secrets](../vantage/secrets.md#kubernetes-external-secrets-operator).
|
||||
|
||||
### Servers
|
||||
## Things worth knowing
|
||||
|
||||
```
|
||||
GET,POST /servers
|
||||
GET,POST /servers/new
|
||||
GET,DELETE /servers/:id
|
||||
POST /servers/:id/generate-key
|
||||
POST /servers/:id/update-agent
|
||||
POST /servers/:id/apply-updates
|
||||
```
|
||||
- **Reads always work. Changes need a valid licence.** Without one, the instance
|
||||
is read-only and any call that changes something is refused. Deleting things,
|
||||
applying OS updates and installing a licence are always allowed, so you can
|
||||
always get back under your allowance or out of read-only mode.
|
||||
- **Some features are licensed.** The browser console, single sign-on and
|
||||
vulnerability scanning are refused if your licence does not include them.
|
||||
- **Some things cannot be changed here.** A cloud instance refuses a pasted
|
||||
licence, and people managed by Vantage HQ cannot be re-roled or deleted inside
|
||||
the instance.
|
||||
- **Errors are JSON**, with an `error` field naming the reason.
|
||||
- **Everything that changes something is audited**, whether it came from the UI
|
||||
or from a script. See [Audit log](../vantage/audit-log.md).
|
||||
|
||||
### Keys
|
||||
## Vantage HQ
|
||||
|
||||
```
|
||||
GET,POST /keys
|
||||
GET,DELETE /keys/:id
|
||||
GET /keys/:id/private-key
|
||||
POST /keys/:id/assign
|
||||
DELETE /keys/:id/assign/:serverId
|
||||
```
|
||||
|
||||
### Workflows and steps
|
||||
|
||||
```
|
||||
GET,POST /steps
|
||||
PUT,DELETE /steps/:id
|
||||
GET /steps/:id/export
|
||||
POST /steps/import · /steps/seed-defaults · /steps/parse
|
||||
GET /steps/usage
|
||||
GET,POST /workflows
|
||||
GET,PUT,DELETE /workflows/:id
|
||||
POST /workflows/:id/run
|
||||
GET /workflows/:id/runs
|
||||
GET /runs/:runId
|
||||
POST /runs/:runId/cancel
|
||||
GET /runs/:runId/servers/:serverId/logs
|
||||
GET /runs/:runId/servers/:serverId/logs/stream
|
||||
```
|
||||
|
||||
`PUT` and `DELETE` on a step whose source is `default` answer `409`. See
|
||||
[Workflows](../vantage/workflows.md#default-steps).
|
||||
|
||||
### Monitors and channels
|
||||
|
||||
```
|
||||
GET,POST /monitors
|
||||
GET,PUT,DELETE /monitors/:id
|
||||
GET /monitors/:id/incidents · /monitors/:id/uptime
|
||||
GET,POST /channels
|
||||
PUT,DELETE /channels/:id
|
||||
POST /channels/:id/test
|
||||
```
|
||||
|
||||
### Secrets
|
||||
|
||||
```
|
||||
GET,POST /secrets
|
||||
GET,PUT,DELETE /secrets/:group
|
||||
POST /secrets/:group/reveal
|
||||
DELETE /secrets/:group/:key
|
||||
```
|
||||
|
||||
### Console
|
||||
|
||||
```
|
||||
POST /console/connect
|
||||
GET /console/tunnel # websocket
|
||||
```
|
||||
|
||||
### Other
|
||||
|
||||
```
|
||||
GET /audit
|
||||
GET /agent/latest-version
|
||||
GET,PUT /settings (owner|admin)
|
||||
POST /settings/secrets-token (owner|admin)
|
||||
GET /license
|
||||
POST /license (self-hosted only)
|
||||
GET,POST /org/users
|
||||
PUT /org/users/:id/role
|
||||
DELETE /org/users/:id
|
||||
GET,POST /auth/providers (owner|admin)
|
||||
PUT,DELETE /auth/providers/:id (owner|admin)
|
||||
POST /auth/providers/:id/test · /auth/providers/:id/ack-notice (owner|admin)
|
||||
GET /auth/presets (owner|admin)
|
||||
```
|
||||
|
||||
## Notable refusals
|
||||
|
||||
| Endpoint | Condition | Status |
|
||||
| -------------------------------------------------- | ------------------------------ | ------------------- |
|
||||
| `POST /license` | deployment is `cloud` | `409 cloud_managed` |
|
||||
| `PUT,DELETE /steps/:id` | the step's source is `default` | `409` |
|
||||
| `PUT /org/users/:id/role`, `DELETE /org/users/:id` | the user's auth source is `hq` | `409` |
|
||||
|
||||
`POST /license` is exempt from the licence check, so pasting a valid licence
|
||||
works while the current one is expired that is the way out of degraded mode.
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
Every request is scoped to the instance resolved from the session. On a
|
||||
multi-tenant deployment, a request arriving at `<slug>.vantage.<tld>` also has
|
||||
its host checked against the session's instance, and a mismatch is rejected.
|
||||
|
||||
## Errors
|
||||
|
||||
Errors are JSON with an `error` field. Customer-facing endpoints in the HQ API
|
||||
answer `404` rather than `403` for another account's resource, because a `403`
|
||||
confirms the resource exists; the control plane's own API is single-tenant per
|
||||
session and does not need that distinction.
|
||||
|
||||
## Admin API
|
||||
|
||||
Vantage HQ is a separate hosted service with its own API and its own session.
|
||||
Its behaviour is described in the [Vantage HQ](../hq/accounts-and-signup.md)
|
||||
section rather than here; the two services share no session and no
|
||||
authentication.
|
||||
The portal is a separate service with its own sign-in, described in the
|
||||
[Vantage HQ](../hq/accounts-and-signup.md) section. A Vantage session does not
|
||||
work there, and an HQ session does not work in your instance.
|
||||
|
||||
@@ -8,13 +8,12 @@ Symptoms, in the order people hit them.
|
||||
|
||||
## The server will not start
|
||||
|
||||
**Exits immediately on boot.** Almost always a missing `GRPC_HOST` the server
|
||||
**Exits immediately on boot.** Almost always a missing `GRPC_HOST`. The server
|
||||
refuses to start rather than guess a value that would break every agent later.
|
||||
|
||||
**Fails during index creation.** The auth and settings index builders are fatal
|
||||
on failure by design: those unique indexes are what enforce tenant isolation,
|
||||
so starting without them is worse than not starting. Check the MongoDB user's
|
||||
permissions and whether a conflicting index already exists.
|
||||
**Fails while preparing the database.** Vantage stops rather than run without
|
||||
the safeguards it sets up at startup. Check the MongoDB user's permissions and
|
||||
whether an old, conflicting index is already there.
|
||||
|
||||
**Starts, but every secret operation errors.** `KEY_ENCRYPTION_KEY` is missing
|
||||
or is not 64 hex characters.
|
||||
@@ -22,15 +21,15 @@ or is not 64 hex characters.
|
||||
## Nobody can sign in
|
||||
|
||||
**`/setup` appears when users already exist.** The server is pointed at a
|
||||
different database than you think. Check the database name in `MONGO_URI` —
|
||||
it comes from the URI path, not a separate variable.
|
||||
different database than you think. Check the database name in `MONGO_URI`,
|
||||
which is taken from the end of the URI.
|
||||
|
||||
**Sessions do not stick.** Redis is unreachable, or the cookie is being dropped
|
||||
because the site is served over plain HTTP.
|
||||
|
||||
**"Wrong organisation" style rejections.** The host and session guard is
|
||||
comparing the request host's label against the session's organisation. Check
|
||||
`APP_ROOT_LABEL`.
|
||||
**"Wrong organisation" style rejections.** Vantage compares the address you
|
||||
browsed to against the instance your session belongs to. On a custom domain,
|
||||
check `APP_ROOT_LABEL`.
|
||||
|
||||
**OIDC redirects and then fails.** The callback URL registered with the provider
|
||||
must match exactly. Keep one local owner account so a broken provider is not a
|
||||
@@ -44,12 +43,12 @@ Work through it in this order:
|
||||
2. What does it say? `journalctl -u vantage-agent -f`.
|
||||
3. Can that machine reach the endpoint? Test `GRPC_HOST` **from the machine**,
|
||||
not from the control plane host.
|
||||
4. Was the token already used or expired? It is single-use and lives one hour —
|
||||
create a fresh enrolment rather than reusing the old command.
|
||||
4. Was the token already used, or older than an hour? Generate a fresh install
|
||||
command rather than reusing the old one.
|
||||
|
||||
| Symptom | Cause |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| Registers, then goes `offline` within minutes | Something permits the short `Register` call but drops the long-lived stream. Usually a proxy or idle-timeout middlebox |
|
||||
| Registers, then goes `offline` within minutes | The short registration call gets through but the long-lived connection is dropped, usually by a proxy or an idle timeout |
|
||||
| Stays `pending` forever | Registration never happened. Token spent, or the endpoint unreachable |
|
||||
| Flaps between `active` and `offline` | Intermittent path, or a poll interval longer than the offline threshold |
|
||||
|
||||
@@ -67,15 +66,15 @@ instantaneous.
|
||||
|
||||
## A workflow run fails or hangs
|
||||
|
||||
- **Hangs at dispatch.** The target's command stream is not connected the
|
||||
- **Hangs at dispatch.** The target's command stream is not connected; the
|
||||
server may be `offline`.
|
||||
- **Fails immediately with an interpreter error.** A bash step on a Windows
|
||||
target, or PowerShell on Linux.
|
||||
- **A value does not reach the next step.** Values pass through the file at
|
||||
`$WORKFLOW_ENV`, one `KEY=value` per line. Declaring an output does not export
|
||||
it.
|
||||
- **A secret is empty.** The group is not in the step's `secret_refs`, or the
|
||||
key name differs from the environment variable you are reading.
|
||||
`$WORKFLOW_ENV`, one `KEY=value` per line. Listing an output does not pass it
|
||||
on by itself.
|
||||
- **A secret is empty.** The group is not attached to that step, or the key name
|
||||
differs from the variable you are reading.
|
||||
- **Logs stop mid-run.** A reverse proxy read timeout cut the stream. The run
|
||||
itself continues; reload the page.
|
||||
|
||||
@@ -85,8 +84,8 @@ instantaneous.
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Connects, then closes at once | guacd unreachable. Check `GUACD_ADDR` and that the container is running |
|
||||
| SSH rejects the key | The stored key has no private half, or is not on the target |
|
||||
| RDP fails on retry | Credentials are single-use and consumed at tunnel open enter them again |
|
||||
| Hangs, then disconnects | The agent never claimed the relay, nothing is listening on the protocol port on the target's own loopback address, or guacd never dialled in time. Check the audit log for `console.proxy_failed` — its reason (`agent_timeout`, `dial_refused`, `guacd_timeout`, `rejected`) names which |
|
||||
| RDP fails on retry | Credentials are single-use and consumed at tunnel open. Enter them again |
|
||||
| Hangs, then disconnects | The agent could not reach the service on that machine, or setting up the session timed out. The audit log records which |
|
||||
| Fails only in production | The reverse proxy is not forwarding WebSocket upgrade headers |
|
||||
|
||||
## Monitors report down when the service is up
|
||||
@@ -98,7 +97,7 @@ instantaneous.
|
||||
|
||||
## Notifications are not arriving
|
||||
|
||||
Use the channel **Test** button it goes through the real delivery path, so a
|
||||
Use the channel **Test** button. It goes through the real delivery path, so a
|
||||
test that arrives proves credentials, network path and destination.
|
||||
|
||||
If the test fails: a webhook returning 300 or above counts as a failure, SMTP
|
||||
@@ -109,9 +108,9 @@ needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
|
||||
|
||||
| Symptom | Cause |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `409 cloud_managed` when pasting | It is a cloud instance. Licences are written by HQ; there is nothing to paste |
|
||||
| Licence rejected as not matching | It is bound to a different instance UUID. Relink in HQ |
|
||||
| Instance degraded despite a valid-looking licence | It has expired past its grace period. Pasting still works that endpoint stays available specifically so it can |
|
||||
| "Managed by Vantage HQ" when pasting | It is a cloud instance, which is licensed for you. There is nothing to paste |
|
||||
| Licence rejected as not matching | It was issued to a different instance ID. Relink it in Vantage HQ |
|
||||
| Instance degraded despite a valid-looking licence | It expired more than a few days ago. Pasting a new one still works, which is how you recover |
|
||||
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
|
||||
|
||||
## HQ portal problems
|
||||
@@ -129,5 +128,5 @@ docker compose logs --tail=200 server
|
||||
journalctl -u vantage-agent --no-pager -n 200 # on the affected machine
|
||||
```
|
||||
|
||||
Include your instance UUID from **Settings → Licence** it is the reference
|
||||
Include your instance ID from the **Licence** page, which is the reference
|
||||
support works from.
|
||||
|
||||
@@ -4,7 +4,8 @@ title: Audit log
|
||||
sidebar_label: Audit log
|
||||
---
|
||||
|
||||
Every mutating API path writes an audit event. The log is at **Audit**.
|
||||
Anything that changes something is recorded, whether it was done in the UI or by
|
||||
a script. The log is at **Audit Log**.
|
||||
|
||||
## What an event carries
|
||||
|
||||
@@ -23,21 +24,24 @@ keys and assignments, workflow and step changes, runs triggered, monitors and
|
||||
channels, secret groups and reveals, console sessions opened, settings and
|
||||
member changes, licence installs.
|
||||
|
||||
Reads are not recorded, with one deliberate exception: **revealing a secret**
|
||||
writes an event, because reading that particular thing is an act rather than a
|
||||
lookup.
|
||||
Simply looking at something is not recorded, with one exception: **revealing a
|
||||
secret** is.
|
||||
|
||||
## What is not recorded
|
||||
|
||||
- Sign-ins and sign-out.
|
||||
- Sign-ins and sign-outs.
|
||||
- Anything inside a console session.
|
||||
- Step output. That lives in the run log, kept under the workflow retention
|
||||
setting rather than with the audit log.
|
||||
|
||||
## Retention
|
||||
|
||||
Audit events are not swept by the workflow log retention setting that setting
|
||||
governs run logs only. Audit history stays until the instance does.
|
||||
How long audit events are kept comes from your licence: 30 days on Free, a year
|
||||
on Professional, and unlimited on Enterprise. See
|
||||
[Licensing and entitlements](../hq/licensing-and-entitlements.md).
|
||||
|
||||
This is separate from the workflow log retention setting, which covers run logs
|
||||
only.
|
||||
|
||||
:::warning It is a log, not a control
|
||||
The audit log tells you what happened. It does not restrict what can happen, and
|
||||
@@ -45,7 +49,11 @@ an admin can do anything an admin can do. Use roles for restriction and the log
|
||||
for accountability.
|
||||
:::
|
||||
|
||||
## Getting events out
|
||||
## Searching and exporting
|
||||
|
||||
`GET /api/audit` returns recent events as JSON and accepts a `limit`. There is
|
||||
no streaming or push export; if you need events in a SIEM, poll that endpoint.
|
||||
The page searches by actor, detail and event type, and filters by category, such
|
||||
as `workflow`, `key` or `server`. The count shown is the number of matching
|
||||
events, not the number on screen.
|
||||
|
||||
The same events are available from the API if you want them in a log system of
|
||||
your own. See [Automating Vantage](../reference/rest-api.md).
|
||||
|
||||
@@ -4,70 +4,59 @@ title: Browser console
|
||||
sidebar_label: Browser console
|
||||
---
|
||||
|
||||
An SSH, RDP or VNC session in a browser tab, with no client software and no
|
||||
inbound port on the target beyond the one the protocol already uses.
|
||||
An SSH, RDP or VNC session in a browser tab, with no client software to install
|
||||
and no new port to open on the target machine.
|
||||
|
||||
Protocol handling is Apache Guacamole's the control plane proxies a WebSocket
|
||||
to a **guacd** daemon and manages credentials around it.
|
||||
:::info Requires the console feature on your licence
|
||||
The console is a per-instance feature you enable on a paid plan. Without it, the
|
||||
Console button is unavailable. See
|
||||
[Licensing and entitlements](../hq/licensing-and-entitlements.md).
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
## What you need
|
||||
|
||||
- `guacd` running and reachable from the server. The bundled Compose stack
|
||||
includes it; `GUACD_ADDR` defaults to `guacd:4822`.
|
||||
- `KEY_ENCRYPTION_KEY` set, since every credential involved is stored encrypted.
|
||||
- The target's **agent must be online**. Console traffic is relayed over the
|
||||
agent's existing outbound connection, so the control plane never needs a route
|
||||
to the server's address — but it does need the agent.
|
||||
- No inbound port on the target, beyond what the protocol already listens on
|
||||
locally. A service bound only to `127.0.0.1` works, because the agent dials
|
||||
loopback on the target itself.
|
||||
- The target server's **agent must be online**. Console traffic travels over the
|
||||
connection the agent already holds, so an offline agent means no session.
|
||||
- `KEY_ENCRYPTION_KEY` set on a self-hosted install, since every credential
|
||||
involved is stored encrypted.
|
||||
- The service you are connecting to listening on the machine itself. It does not
|
||||
have to be reachable from anywhere else, because the agent connects to it
|
||||
locally.
|
||||
|
||||
## Opening a session
|
||||
|
||||
From a server's page, choose **Console**. Then:
|
||||
|
||||
1. The UI calls `POST /api/console/connect`, which mints a **one-time** session
|
||||
token. If the target's agent is not connected, this fails immediately with
|
||||
`409 agent_offline` rather than hanging.
|
||||
2. The browser opens a WebSocket to `GET /api/console/tunnel` with that token.
|
||||
3. The server marks the token consumed atomically, so a second use cannot
|
||||
race and proxies the connection to guacd.
|
||||
From a server's page, choose **Console**, pick the protocol and connect. Vantage
|
||||
issues a one-time ticket for that session, and the connection is refused rather
|
||||
than left hanging if the agent is not online.
|
||||
|
||||
## Credentials
|
||||
|
||||
### SSH
|
||||
|
||||
Authenticates with a private key stored in the [key library](./ssh-keys.md). The
|
||||
key must have its private half uploaded; a public-only key cannot open a
|
||||
session.
|
||||
Uses a private key from your [key library](./ssh-keys.md). The key must have had
|
||||
its private half uploaded; a public key alone cannot open a session.
|
||||
|
||||
### RDP and VNC
|
||||
|
||||
You supply credentials when connecting. They are encrypted, **single-use**, and
|
||||
consumed when the tunnel opens. They are not retained for the next session.
|
||||
You type the credentials when you connect. They are encrypted, used once and
|
||||
discarded, so the next session asks again.
|
||||
|
||||
:::info Why single-use
|
||||
A stored console credential is a standing grant to that machine for anyone who
|
||||
can reach the endpoint. Consuming it at tunnel-open means a leaked session token
|
||||
is worth one connection at most, and only until it is used.
|
||||
:::
|
||||
## During and after a session
|
||||
|
||||
## Session behaviour
|
||||
Closing the tab ends the session. There is no reconnect: opening it again starts
|
||||
a fresh session.
|
||||
|
||||
Closing the tab ends the session. There is no reconnect and no session
|
||||
persistence reopening mints a new token and a new connection.
|
||||
|
||||
## Auditing
|
||||
|
||||
Opening a console is an audited action, with actor, server and time. What
|
||||
happens _inside_ the session is not recorded: there is no session capture or
|
||||
keystroke log. If you need that, it has to come from the target machine.
|
||||
Opening a console is recorded in the [audit log](./audit-log.md), with who did
|
||||
it, which server and when. What happens inside the session is not recorded.
|
||||
There is no session replay or keystroke capture, so if you need that, it has to
|
||||
come from the target machine itself.
|
||||
|
||||
## When it does not work
|
||||
|
||||
| Symptom | Cause |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Connects then closes immediately | guacd unreachable check `GUACD_ADDR` and that the container is up |
|
||||
| SSH refuses the key | The stored key has no private half, or is not in the target's `authorized_keys` |
|
||||
| RDP fails on a fresh credential | Credentials are consumed on open; a retry needs them entered again |
|
||||
| Hangs, then disconnects | The agent never claimed the relay, nothing is listening on the protocol port on the target's own loopback address, or guacd never dialled in time. Check the audit log for `console.proxy_failed` — its reason (`agent_timeout`, `dial_refused`, `guacd_timeout`, `rejected`) names which |
|
||||
| Symptom | What to check |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Connects, then closes at once | The console daemon is unreachable. On a self-hosted install, check that `guacd` is running |
|
||||
| SSH refuses the key | The stored key has no private half, or is not assigned to that server |
|
||||
| RDP fails when you retry | Credentials are used once. Enter them again |
|
||||
| Hangs, then disconnects | The agent could not reach the service on the machine, or the session timed out setting up. The audit log records the reason |
|
||||
| Works locally, fails in production | Your reverse proxy is not forwarding WebSocket connections |
|
||||
|
||||
@@ -28,22 +28,22 @@ Every monitor has a **runner**:
|
||||
| `server` | The control plane's scheduler performs the check |
|
||||
| a server ID | That server's agent performs it locally and reports the result |
|
||||
|
||||
Use `server` for anything reachable from the control plane public endpoints,
|
||||
your own front door. Use an agent for anything only reachable from inside the
|
||||
Use `server` for anything Vantage itself can reach, such as your public website
|
||||
or API. Use an agent for anything only reachable from inside the
|
||||
target network: a database on a private subnet, a service bound to localhost, a
|
||||
device on a management VLAN.
|
||||
|
||||
:::tip Agent-run monitors measure what your users can't
|
||||
A check run from the control plane tells you the service is reachable from
|
||||
there. A check run on the machine tells you the process is alive. Those are
|
||||
different questions, and outages usually live in the gap.
|
||||
:::tip The two answer different questions
|
||||
A check from Vantage tells you the service is reachable over the network. A
|
||||
check on the machine tells you the process is running. Watch both where it
|
||||
matters.
|
||||
:::
|
||||
|
||||
## Interval, retries and state
|
||||
|
||||
- **Interval** how often to check.
|
||||
- **Retries** how many consecutive failures are tolerated before the state
|
||||
flips.
|
||||
- **Interval** is how often to check.
|
||||
- **Retries** is how many failures in a row are tolerated before the state
|
||||
changes.
|
||||
|
||||
A monitor sits in `pending` until its first result. Failures accumulate; once
|
||||
they exceed `retries`, the monitor goes `down`, an **incident** opens and the
|
||||
@@ -58,21 +58,20 @@ Attach one or more [notification channels](./notification-channels.md) to a
|
||||
monitor. Channels are shared, so one Slack destination can serve every monitor
|
||||
you have.
|
||||
|
||||
Notification state is tracked per monitor, so a service that is down for six
|
||||
hours does not send a message per interval.
|
||||
You get a message when a monitor goes down and another when it recovers, not one
|
||||
per check while it stays down.
|
||||
|
||||
## Uptime and incidents
|
||||
|
||||
The monitor detail page shows:
|
||||
|
||||
- **Uptime**, from hourly rollup records checks performed, how many were up,
|
||||
and mean latency per hour. Rollups are what make the graph cheap to draw over
|
||||
long windows.
|
||||
- **Uptime**, summarised per hour: how many checks ran, how many passed and the
|
||||
average response time.
|
||||
- **Incidents**, each with a start, a resolution and the cause recorded at the
|
||||
moment it opened.
|
||||
|
||||
## Disabling versus deleting
|
||||
|
||||
Disabling stops the checks and keeps the history. Deleting removes the monitor.
|
||||
Prefer disabling for anything seasonal the uptime record is usually the part
|
||||
you wanted.
|
||||
Disable anything seasonal rather than deleting it, since the uptime record is
|
||||
usually the part worth keeping.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user