Compare commits

...
Author SHA1 Message Date
mrhid6 2bc15648b7 docs: Describe the Windows agent's update and workload support
Chart Release / chart (push) Successful in 34s
Server Deploy / deploy (push) Failing after 5m34s
Agent Release / build (push) Successful in 12m50s
Agent Release / msi (push) Successful in 4m22s
2026-08-13 12:23:34 +00:00
mrhid6 7f348b7b2b fix: Follow Windows Th/Td labels through the updates table 2026-08-13 12:19:47 +00:00
mrhid6 a71fd9a9f4 fix: Fixed docs entitlement 2026-08-13 12:18:21 +00:00
mrhid6 36848a519a feat: Word the workload and update panels for Windows servers 2026-08-13 12:16:10 +00:00
mrhid6 ea6d0b969c fix: Trim Windows event log after SCM filtering, not before 2026-08-13 12:12:11 +00:00
mrhid6 4ba9983cf8 feat: Control Windows services and read their event log as workloads 2026-08-13 12:04:49 +00:00
mrhid6 8caaa4540f fix: Respect .exe word boundary and unterminated quotes in servicePath 2026-08-13 12:00:59 +00:00
mrhid6 42f0cb67cb feat: Collect Windows services as workloads 2026-08-13 11:52:08 +00:00
mrhid6 1dfb3cc28c refactor: Split the agent workloads package by build tag 2026-08-13 10:49:24 +00:00
mrhid6 b51e87477e feat: Report whether a managed host is waiting on a reboot 2026-08-13 10:39:27 +00:00
mrhid6 05c0ad43d2 feat: Check and apply Windows updates through the Windows Update COM API 2026-08-13 10:36:22 +00:00
mrhid6 4a07af049c refactor: Split the agent updates package by build tag 2026-08-13 10:33:35 +00:00
mrhid6 057c193e26 feat: Add winexec helper for running PowerShell from the agent 2026-08-13 10:26:31 +00:00
mrhid6 38a731f269 docs: Add implementation plan for Windows agent parity 2026-08-13 10:21:04 +00:00
mrhid6 06d69590fc docs: Design for Windows agent parity on updates and workloads 2026-08-13 10:08:25 +00:00
mrhid6 6adee810dc feat: Cleanup old docs 2026-08-13 09:41:32 +00:00
mrhid6 00d4307346 fix: Fixed chart for ingress
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m40s
2026-08-13 09:29:01 +00:00
mrhid6 7e767ecb4f fix: Move the API keys page off the /api prefix
/api-keys shares a raw string prefix with /api, and the proxies in front
of this app do not all match by path segment. Nginx Proxy Manager routes
/api straight to the Go server with a prefix location, so /api-keys never
reached Next at all — it reached a control plane with no such route and
came back as a JSON 404. Traefik's PathPrefix has the same shape of
matcher, which puts the Helm ingress at risk whenever ingress.api.enabled
is on.

The page is /tokens now, which cannot collide with anything, and which
matches the /api/tokens the REST API already publishes. The sidebar still
says API Keys — the label is for the reader, the path is for the router.

A permanent redirect covers anyone who bookmarked the old path today.
Fixing the proxy config instead would have left the trap set for the next
deployment, and for whatever sits in front of it.
2026-08-13 09:26:34 +00:00
mrhid6 18495dba68 feat: Restyle the API keys page onto the shared list patterns
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 7m54s
The page was hand-rolling its loading spinner, error line and empty
paragraph while the rest of the console routes these through
AsyncBoundary with a TableSkeleton and an EmptyState — the same drift
Async.tsx was written to end. It also passed className="p-0" where Card
takes padding={false}.

The admin-only scope switch becomes the vulnerabilities page's pill
filter rather than a loose checkbox: seeing everyone's keys is a filter
over the list, not a preference, and someone who has learned one control
has now learned both.

Scopes collapse to one chip per resource with an r/rw qualifier. Sixteen
badges made the row taller than everything around it and still had to be
read one at a time.

In the create dialog Copy is now the primary action and Done the quiet
one, because the value is unrecoverable once the dialog closes, and the
result panel repeats the role, scopes and expiry that were just granted.
2026-08-13 08:59:06 +00:00
mrhid6 689d0e1d5b feat: Give API keys their own page and group the sidebar
The token management card sat on /settings, which is owner|admin
throughout, so it hid a capability every member already had: the API has
never required a role to mint or revoke your own key. It is now the
/api-keys page, reachable at every role, with the instance-wide lifetime
cap left behind on /settings because that is policy rather than one
person's credentials — and that split is what lets the page be ungated.

The sidebar gains groups: Fleet, Access, Automation, Instance, each with a
small-caps heading and a rule above it. Grouping is by what the operator
is doing rather than by which service answers, so SSH keys, secrets and
API keys sit together as credentials. A group whose every item is
admin-only disappears whole for a member; a labelled section with nothing
under it reads as a failure rather than a restriction.

The UI says keys while the collection, prefix and routes still say tokens.
Renaming a published endpoint to match a nav label would break every
script already written against it.
2026-08-13 08:54:42 +00:00
mrhid6 95527b3956 fix: Exclude /install and /update scripts from the OpenAPI document
handleInstallScript and handleUpdateScript are registered on the bare
gin engine at /install and /update, outside the /api group the
generated document's BasePath assumes. Their @Router annotations
therefore published /api/install and /api/update, paths that 404 —
the reference page told a reader to curl a URL that does not exist.

Removed the swag annotations from both handlers (replaced with a plain
comment explaining why) rather than adding a corrected @Router, since
swag has no per-route BasePath override and there is nothing lost by
leaving two shell-script endpoints out of a JSON API reference — their
.ps1 counterparts were already undocumented for the same reason.
Regenerated internal/api/docs/openapi.json accordingly.
2026-08-13 08:31:20 +00:00
mrhid6 225b53bfa7 fix: Throttle audit logging for expired API token use
Every request presenting an expired token wrote a token.expired_use
audit row, and RateLimitTokens only applies once a session exists, so a
rejected token was never rate-limited. A looping job with one expired
token could write an unbounded number of audit rows, drowning the real
audit trail.

services.ShouldLogExpiredTokenUse now dedupes to at most one
token.expired_use record per token per minute, mirroring the throttle
TouchAPIToken already uses for last-used. It lives in services rather
than auth because the storage concern belongs beside the token's other
storage-backed state. The first use per window is still recorded, which
is what makes a forgotten job visible.
2026-08-13 08:31:12 +00:00
mrhid6 965419b2b8 fix: Confine created API token scopes to the calling token's own
CreateAPIToken capped a new token's role at the creator's role but never
capped its scopes against the calling credential's scopes, and POST
/api/tokens required only settings:write. A token holding settings:write
alone could therefore mint a token holding keys:write or secrets:write,
reaching every SSH private key and vault secret in the instance.

createToken now refuses (403 scope_confinement) when the calling
credential is itself a token and any requested scope is not satisfied by
that token's own scopes, via services.ScopeSatisfied so servers:write
still permits granting servers:read. Cookie sessions are unaffected,
since their authority is the user's role. Also correct the createToken
doc comment, which claimed the scope cap already existed.

Also document why Hint stores 5 hex characters of the token secret.
2026-08-13 08:31:07 +00:00
mrhid6 f6988b0f1e docs: Correct API token access-control claim in CLAUDE.md 2026-08-13 08:12:48 +00:00
mrhid6 0784ef3719 docs: Document API tokens and the OpenAPI reference 2026-08-13 08:04:09 +00:00
mrhid6 9df4a29210 fix: Separate stacked securityDefinitions into distinct comment groups
swag v2.0.0-rc5's parseSecAttributesV3 resolves a security scheme's map key
via getSecurityDefinitionKey(lines), which scans from the start of whatever
comment-line slice it was handed and returns the first @securitydefinitions
match — ignoring the current parse position entirely. Three
@securityDefinitions.apikey blocks stacked in one Go comment group (the
three were separated only by bare '//' lines, which do not split an
ast.CommentGroup) therefore all resolved to the first block's name
(cookieAuth), with the last block's in/name/description winning: the
generated document had exactly one securityScheme, keyed cookieAuth, body
esoAuth.

Separating the three blocks with real blank source lines splits them into
three distinct ast.CommentGroups, so swag's file-level comment scan (which
requires no other tokens between them, same rule Go uses for doc comments)
hands each block its own line slice and each resolves its own key.
Regenerated openapi.json now carries all three schemes with correct
bodies, referenced with no dangling security requirements.
2026-08-13 07:54:56 +00:00
mrhid6 4c88d6e768 feat: Publish an OpenAPI 3.1 document and a Scalar reference
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 10m17s
Generated from swaggo v2 annotations, committed rather than built into the
image: the runtime stage is scratch and adding codegen puts the toolchain
in the build. CI regenerates and diffs, so an annotation edited without
regenerating fails the build — without that the annotations would drift
while still looking authoritative.

Scalar is vendored rather than loaded from a CDN, because air-gapped
self-hosted installs are supported and a reference page that fails closed
offline is a support ticket.
2026-08-12 15:23:02 +00:00
mrhid6 a85a354e57 feat: Annotate workflow, step, run and workload routes
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. This is the last of the handler files under
server/internal/api/.
2026-08-12 15:22:54 +00:00
mrhid6 9b18d09d9b feat: Annotate monitor, secrets and vulnerability routes
Same treatment: named types replace gin.H literals, and every handler gets
a swaggo doc block. VulnSummaryResponse uses pointer fields so the
db-freshness block stays entirely absent when no vulndb_meta document
exists yet, matching the handler's original conditional gin.H exactly.
2026-08-12 15:22:51 +00:00
mrhid6 a398da0eac feat: Annotate SSO, channel, console, instance and licence routes
Same treatment as the previous commit: named types replace gin.H literals,
and every handler gets a swaggo doc block.
2026-08-12 15:22:48 +00:00
mrhid6 edb8406e05 feat: Annotate server, key and token routes for OpenAPI
Converts their gin.H responses to the named types added in the previous
commit and adds swaggo doc blocks for every handler in handlers.go and
tokens.go.
2026-08-12 15:22:45 +00:00
mrhid6 bfd185adbb feat: Add OpenAPI response types and top-level swag annotations
Named response types for handlers that were returning anonymous gin.H
literals, so a generated annotation and what the handler actually returns
cannot disagree. main.go carries the top-level swaggo info block (title,
description, security schemes for cookie, bearer token and ESO auth).
2026-08-12 15:22:42 +00:00
mrhid6 182752d9ab feat: Manage API tokens from settings
A card in the Access group beside Members and single sign-on rather than a
new nav entry — /settings/instance was folded back in for exactly this
reason. The plaintext is shown once in a well block and never again.

Tokens outside a newly tightened lifetime policy are flagged rather than
broken, because the policy governs issuance, not existing credentials.
2026-08-12 14:58:57 +00:00
mrhid6 3b4c87a292 feat: Rate limit API token requests
600 per minute per token, in the Redis that sessions already require.
Cookie sessions are untouched. A Redis failure falls through rather than
refusing traffic — it is already a larger problem and should not become a
second outage.
2026-08-12 14:51:22 +00:00
mrhid6 2685e9ad06 fix: Distinguish caller mistakes from backend failures in CreateAPIToken
createToken's catch-all mapped every unmatched error to 400, so a
database outage reported itself as a malformed client request. Wrap the
genuine validation failures with ErrTokenInvalid and let the handler
answer 500 with a fixed message for everything else.
2026-08-12 14:48:17 +00:00
mrhid6 4de67e4bea docs: Separate caller mistakes from backend failures in the plan
createToken's catch-all answered 400 for every unmatched error, so a
database failure reported itself as the caller's malformed request. Found
in review of Task 7.
2026-08-12 14:47:36 +00:00
mrhid6 524ccc6412 feat: Add the API token endpoints
Create, list and revoke, with no update: editing what a credential already
deployed in CI can do, with no record of what it could do before, is worse
than requiring a rotation. Revoking a token that is not yours answers
not-found, since a 403 confirms it exists.

The audit actor stays the human and names the credential alongside, so a
person clicking and their CI job are told apart.
2026-08-12 14:44:50 +00:00
mrhid6 be4f488db3 feat: Enforce API token scopes from the route map
Keyed on the registered gin route pattern rather than a per-route
decorator, because a route registered without a decorator would be
unguarded. An unmapped route reached by a token is a 403, and a boot-time
check refuses to start when any /api route is missing, so the failure
lands at deploy rather than as a customer's surprise 403.
2026-08-12 14:40:00 +00:00
mrhid6 2f60b81962 fix: Stop the session middleware writing two responses on an expired cookie
sessionFromCookie already writes "session expired" when a cookie was
presented and rejected with no bearer to fall through to. Middleware
called sessionFromToken anyway, which wrote a second "not authenticated"
body onto the same response for every ordinary browser-session timeout -
gin logged "superfluous response.WriteHeader call" on ordinary use, not a
rare edge case.

Guard on c.IsAborted() after sessionFromCookie: true only in that one
rejected-cookie-no-bearer branch, so it short-circuits there while the
other three credential paths (no credential, bearer only, stale cookie
plus valid bearer) are unaffected.
2026-08-12 14:35:20 +00:00
mrhid6 92692de94d docs: Correct the middleware fallback in the plan
The plan's Middleware called sessionFromToken even when sessionFromCookie
had already answered a rejected cookie, putting two JSON bodies on the
wire for the ordinary expired-session case. Found in review of Task 5.
2026-08-12 14:34:54 +00:00
mrhid6 a5f9fca59e feat: Authenticate the API with a bearer token as well as a cookie
One middleware, two ways to arrive at the same *Session, so every handler,
role guard, licence gate and audit call is untouched. The host guard
applies to both: a token carries an instance, and the tenant boundary must
not have a token-shaped hole in it.

The effective role is min(user, token) recomputed per request, so demoting
somebody demotes their tokens with them. A stale cookie beside a valid
bearer falls through rather than refusing a credential that would work.
2026-08-12 14:32:40 +00:00
mrhid6 72e5228351 feat: Add the API token service
Mint, resolve, list and revoke, with the effective role capped at the
owner's and recomputed per request rather than frozen at creation.

Deleting a user deletes their tokens in the same call, so offboarding is
one action. Revoking somebody else's token answers not-found rather than
forbidden, since a 403 confirms the credential exists.

Also re-exports shared.APITokenMaxDays into server/internal/models,
following the existing ValidRole wrapper pattern, since the token
service needs it and it was never re-exported.
2026-08-12 14:28:10 +00:00
mrhid6 33b5ec0788 feat: Add a per-instance API token lifetime cap
A pointer with absent meaning no cap, so an upgrade allows never-expire
tokens exactly as before and an instance opts into the policy. It governs
issuance only: changing it never invalidates a token that already exists.
2026-08-12 14:22:12 +00:00
mrhid6 1b718e7c59 feat: Define the API token scope vocabulary
Eight resources with read and write, write implying read. Coarse on
purpose: a scope per endpoint is a table nobody maintains, and a route
added without an entry either breaks or is unguarded.
2026-08-12 14:19:47 +00:00
mrhid6 6ad65a1242 feat: Add the api_tokens collection and its indexes
The unique index on token_hash is what makes authentication an indexed
lookup rather than a scan, so this builder is fatal on failure like
EnsureAuthIndexes rather than warning like the secrets one.

Registered in ScopedCollections so instance purge reaches it.
2026-08-12 14:16:12 +00:00
mrhid6 a41f2b26cc docs: Plan the API token and OpenAPI implementation
Twelve tasks from model through middleware, scope enforcement, endpoints,
web UI, generated OpenAPI and documentation. Verification is build plus
curl and UI checks rather than test cycles, matching a repository with no
Go test harness beyond shared/mail.
2026-08-12 14:09:03 +00:00
mrhid6 71f9a9dca5 docs: Specify scoped API tokens and an OpenAPI reference
Adds the approved design for personal access tokens on the control plane
REST API, and for the generated OpenAPI 3.1 document served as a Scalar
reference page.

Tokens fall back into the existing session middleware rather than getting
their own route group, so every handler, role guard and audit call works
unchanged. Scope enforcement derives from the registered route pattern and
fails closed, with a boot-time check for unmapped routes.

A Terraform provider is deliberately left to a follow-on spec.
2026-08-12 13:59:03 +00:00
mrhid6 449684ceaa fix: Derive the rename unwind deadline at its use site
Chart Release / chart (push) Successful in 17s
Server Deploy / deploy (push) Successful in 5m58s
2026-08-12 11:15:29 +00:00
mrhid6 cdc50b7aaf fix: Harden instance rename against interleaving and lost unwinds 2026-08-12 11:08:36 +00:00
mrhid6 f534b74066 docs: Document instance rename in HQ 2026-08-12 10:52:43 +00:00
mrhid6 3c15ee15ef feat: Let staff rename an instance 2026-08-12 10:29:45 +00:00
mrhid6 45a4f968d6 feat: Let a customer rename a cloud instance from HQ 2026-08-12 10:23:17 +00:00
mrhid6 df09e42cf2 feat: Add rename calls and slug preview to the HQ client 2026-08-12 10:17:39 +00:00
mrhid6 ee427ed6e1 feat: Add staff instance rename endpoint 2026-08-12 10:12:04 +00:00
mrhid6 5856deede3 feat: Add customer instance rename endpoint 2026-08-12 10:09:19 +00:00
mrhid6 e02b263054 feat: Add rename cooldown field and cloudprov rename 2026-08-12 10:06:31 +00:00
mrhid6 1d6c89c368 feat: Add instance rename to shared provisioning 2026-08-12 10:01:56 +00:00
mrhid6 0edfddb710 docs: Drop test steps from the rename plan 2026-08-12 09:58:18 +00:00
mrhid6 71a4f53bed docs: Implementation plan for instance rename in HQ 2026-08-12 09:53:40 +00:00
mrhid6 de7350fce9 docs: Reuse loginURLFor for the rename response host 2026-08-12 09:46:38 +00:00
mrhid6 21786fa1a8 docs: Design for instance rename in Vantage HQ 2026-08-12 09:46:08 +00:00
mrhid6 84f9587b9a fix: Fixed gitignore
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 25s
2026-08-11 15:03:03 +00:00
mrhid6 a20e165ac2 feat: Revamp instance page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 37s
2026-08-11 10:32:59 +01:00
mrhid6 ed260cd86c feat: Updated members panel on instance page
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 1m24s
2026-08-11 10:10:44 +01:00
mrhid6 99cd2a8ec3 feat: Updated admin instance page
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 41s
2026-08-11 09:59:27 +01:00
mrhid6 a228ab24a0 feat: Changes to self hosted purchase
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 9m18s
2026-08-11 09:30:19 +01:00
mrhid6 3acbf01d46 docs: Self review of doc pages
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 3m24s
2026-08-10 16:32:23 +01:00
mrhid6 675689a458 feat(audit): server-side paging, search and category filter; one event format
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 8m2s
The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.

Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.

Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.

audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.

Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
2026-08-10 15:25:48 +01:00
mrhid6 42f3f3e640 feat(web): typed confirmation for deleting an SSH key
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 1m37s
The last of the inline two-step deletes, and the one with the most reach: a
key delete revokes it from every server at once, and for a generated key the
stored private half goes with it. That copy is the only one Vantage holds, so
unlike an uploaded key this cannot be undone by pasting the public half back.

Body names how many servers lose access, and says the private key is destroyed
only when there is one to destroy.

The delete error moves out of the toast and into the dialog, which stays open
on failure, matching the server and monitor deletes.
2026-08-10 15:07:52 +01:00
mrhid6 21238fe707 feat(web): typed confirmations on destructive actions; toasts for API outcomes
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 48s
Destructive actions were confirmed by a second danger button rendered where
the first one had been, so a double click on Remove deleted the thing without
the operator ever reading which thing it was. Servers, monitors, secret keys
and sign-in providers now go through ConfirmDialog with requireTyped, matching
the secret-group delete that already worked this way. Notification channels and
vulnerability alert rules get an untyped dialog: both are a name and a URL and
are rebuilt in a minute, but neither had any confirmation at all, and both
silently stop alerts that nobody misses until an incident goes unannounced.

Mutations otherwise succeeded in silence, or reported into whatever inline
banner the page happened to own. Two failure modes came of that: a modal that
closed on error left the message nowhere to land, and a save that was rejected
left the old values on screen looking exactly like a save that worked
(/settings had no error path at all). Every mutation now reports through the
existing toast context.

Errors stay inline where the surface that raised them is still on screen and
the message is a correction to make in it: form validation, the cron field,
the tag rows, a rejected licence blob, and the workflow designer's autosave,
which is a standing condition rather than an event. Everything else toasts.

Ad-hoc feedback removed in favour of it: the "Sent!" button labels on the
server maintenance tab, the settings "Saved!" flag, the channel test line, and
the steps page's notice/error pair.
2026-08-10 14:26:02 +01:00
mrhid6 ef86ef04a1 fix: Fixes to command stream
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 2m36s
2026-08-10 14:07:17 +01:00
mrhid6 727bb09eff feat: More updates to hq
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Failing after 1m11s
2026-08-10 13:56:59 +01:00
mrhid6 1fe4ba5999 feat: HQ redesign
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 1m19s
2026-08-10 10:44:30 +01:00
mrhid6 e434beec7a fix(web): toasts survive an open dialog; empty-state actions can be gated
Both from the final review, and the first one overturns a call I got wrong.

The aria-hidden sweep that makes aria-modal true also swallowed the toasts.
ToastProvider renders inside the app root, and every modal-raised confirmation
is toasted *before* its dialog closes — "Saved …", "Deleted …", "Removed …" —
so each one was inserted into a hidden subtree and never announced. Un-hiding
a live region afterwards does not replay what it missed. The toast layer is
portalled to the body carrying the dialog-layer attribute, which exempts it
from the sweep, and sits above the dialog: a toast explaining why a dialog's
action failed is no use behind it.

EmptyState's action was narrower than the call site it replaced. The old
first-workflow button carried loading={isPending}; the new one carried
nothing, so a double click created two workflows. The action is a union now —
a link takes no pending state, a handler takes loading and disabled.
2026-08-10 10:01:47 +01:00
mrhid6 fe1dfe472a refactor(web): list pages share the async primitives; dialogs hide the page behind
Keys, secrets, audit, steps and workflows each carried the same
loading/error/empty ternary with its own copy of the spinner and its own
wording for a failed fetch — five of them had drifted to five different
sentences for "the request did not come back". They go through AsyncBoundary
now, which means a skeleton in place of a spinner, a retry button on failure,
and backend messages passed through friendlyMessage rather than printed raw.

Steps gets the filtered-empty state the fleet just got: "no steps match that
filter" is not "no steps yet", and only one of them should offer to create the
first one.

Two more from review:

Modal's effect ran before `mounted` flipped, so a dialog rendered already open
found null refs and took no focus at all. It depends on `mounted` now.

aria-modal was a claim with no mechanism behind it — portalled to the body,
the app tree is a sibling of the dialog and a screen reader's virtual cursor
still browsed the page underneath. The body's other children are marked
aria-hidden while any dialog is open, refcounted alongside the scroll lock.
This does mean a toast raised while a dialog is open is not announced, which
is the correct trade for a modal: ConfirmDialog shows its own errors inline.
2026-08-10 09:54:32 +01:00
mrhid6 0cfaf6670c fix(web): address review of the dialog, toast and async primitives
Two of these were real defects in the previous two commits.

friendlyMessage discarded exactly the messages it claimed to keep: the
"is this a bare reason phrase" test was a shape regex, and "Default steps
cannot be edited" has the same shape as "Not Found". It is an exact-match set
of reason phrases now.

Modal depended on onKeyDown, which is rebuilt whenever onClose changes
identity — and onClose is an inline arrow at every call site, so any parent
re-render (a 30s poll, a mutation flipping to pending) tore the effect down
and rebuilt it: cleanup restored focus to the trigger, setup then moved it to
the top of the dialog, mid-typing. onClose is held in a ref and the effect is
keyed on `open` alone.

Also in Modal: initial focus takes the first control in the body rather than
the panel, since the header comes first in DOM order and every dialog was
opening on its own dismiss button; the Tab trap pulls focus back when it has
escaped the panel entirely rather than only handling the two ends; the scroll
lock is refcounted, because per-instance save/restore released the page when
an outer dialog unmounted under an open inner one; and the whole thing is
portalled to the body so a nested confirm is not clipped by its parent's
overflow box.

The fleet's filtered-empty state keyed on the search alone, so a tag filter
matching nothing told a customer with a full fleet to add their first server.

Remaining: pending mutation errors are reset when a confirm dialog closes, so
one member's failure no longer greets the next; ConfirmDialog clears typed
confirmation when the target changes, not only when it reopens; deleting a
workflow closes its dialogs before navigating rather than carrying a scroll
lock onto the next page; toasts split into a polite and an assertive region,
since one polite wrapper demotes the role="alert" children inside it; and a
custom skeleton gets a live "Loading" beside it, having been aria-hidden with
nothing else to announce.
2026-08-10 09:44:29 +01:00
mrhid6 4d67341ba5 feat(web): fleet search and sort, shared empty/error states, no background polling
The fleet list had a tag filter and nothing else: no search, no sort, and an
unbounded list. Searching hostname/address/OS and sorting by hostname, status
or last seen are all client-side, since the browser already holds the fleet
the page just fetched. Sorting by status orders by how much attention each
state wants rather than alphabetically, which is the only reason to sort by it.

The filtered count is shown beside the total so a search does not read as the
fleet having shrunk, and "no results" is a distinct empty state from "no
servers", with a way back out of the search.

refetchIntervalInBackground defaults to false on the query client. Polling
pages kept refetching in a hidden tab — the fleet list pulls inventory blobs
every 30s — so a console left open in a background tab polled until its
session expired. It belongs in the defaults because the argument is identical
on every polling page.
2026-08-10 09:35:39 +01:00
mrhid6 1fa9160c59 fix(web,adminsite): accessible dialogs, real confirmations, shared async UI
Four correctness/accessibility defects and the destructive-action flow.

- Button: the loading spinner carried xmlns="http://www.w3.instance/2000/svg",
  a find/replace of "org" that landed inside a URL. Button also grows an href
  form, because <Link><Button> nested a button inside an anchor at nineteen
  call sites: invalid markup, two tab stops, and Enter firing only the anchor.

- Fleet status was four meanings carried by hue with the distinction living in
  a title attribute, which touch never shows and screen readers need not
  announce. It now carries a text label and an accessible name, which is the
  one rule the design system states outright.

- Modal had no focus management at all: no trap, no initial focus, no restore,
  no scroll lock, no aria-labelledby. Dialogs nest (a confirm over an edit), so
  a stack decides which panel owns Escape and Tab.

- Seven destructive actions went through window.confirm(). ConfirmDialog
  replaces them and can say what is about to happen; deleting a secret group,
  a shared base step or a workflow now requires typing the name, since those
  have no undo and a wide blast radius. adminsite keeps its own inline idiom
  rather than importing a dialog system it does not have.

Adds Toast, AsyncBoundary/EmptyState/ErrorState/TableSkeleton and
friendlyMessage, replacing per-page loading ternaries and raw
(error as Error).message text. Wired here only where a call site was already
being edited; the remaining pages follow.
2026-08-10 09:25:53 +01:00
mrhid6 d559cccd44 feat: Updated server page
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 45s
2026-08-07 16:21:17 +01:00
mrhid6 0684d84609 fix: Fixed vuln score
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 2m40s
2026-08-07 15:33:50 +01:00
mrhid6 78f1bf853c fix: More fixes to vuln matching
Chart Release / chart (push) Successful in 31s
Server Deploy / deploy (push) Successful in 1m42s
2026-08-07 13:29:15 +01:00
mrhid6 e28238191d feat: Added vuln filter
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 2m29s
2026-08-07 11:58:42 +01:00
mrhid6 82bcc5776f fix: Fixed vuln scanning
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 4m7s
2026-08-07 11:13:58 +01:00
mrhid6 1993802c38 feat: Updated rescan button text 2026-08-07 10:52:16 +01:00
mrhid6 5db49b6b0e feat: Vuln debug logs
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m47s
2026-08-07 10:50:08 +01:00
mrhid6 0c15b25ecd fix: Fixed agent package version
Server Deploy / deploy (push) Successful in 14s
Chart Release / chart (push) Successful in 26s
Agent Release / build (push) Successful in 11m32s
Agent Release / msi (push) Successful in 1m9s
2026-08-07 10:21:00 +01:00
mrhid6 0c21765da3 feat: Added pagination
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m39s
2026-08-07 09:59:50 +01:00
mrhid6 4ff8fc8d51 docs: document the workload registry
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 8m45s
Agent Release / build (push) Successful in 10m54s
Agent Release / msi (push) Successful in 2m31s
2026-08-07 09:09:18 +01:00
mrhid6 483053b9a2 feat: workload registry UI 2026-08-07 09:06:36 +01:00
mrhid6 fd4c51f3db feat: workload registry REST API 2026-08-07 09:01:46 +01:00
mrhid6 1b351cfca4 feat: agent reports workloads and handles workload commands 2026-08-07 08:58:46 +01:00
mrhid6 cf9d85b3cd feat: store workload reports and route log results 2026-08-07 08:56:26 +01:00
mrhid6 6a4ef5b6c6 feat: workload registry proto messages 2026-08-07 08:53:33 +01:00
mrhid6 501cf4e733 feat: agent reads bounded workload logs 2026-08-07 08:49:42 +01:00
mrhid6 89c21d752a feat: agent control actions with self-protection 2026-08-07 08:48:27 +01:00
mrhid6 0e38d9d500 feat: agent enumerates systemd services 2026-08-07 08:46:59 +01:00
mrhid6 3511c34daa feat: agent enumerates docker containers 2026-08-07 08:46:03 +01:00
mrhid6 0838d1d735 feat: models and indexes for the workload registry 2026-08-07 08:45:00 +01:00
mrhid6 d1769fc886 feat: Updated vuln style
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
2026-08-06 16:35:04 +01:00
237 changed files with 28026 additions and 14901 deletions
-24
View File
@@ -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"
}
]
}
]
}
}
+18
View File
@@ -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
View File
@@ -14,4 +14,5 @@ installer/checksums-msi.txt
.next
*.tsbuildinfo
graphify-out
docker-compose.live.yml
docker-compose.live.yml
.claude
+194 -6
View File
@@ -315,6 +315,17 @@ 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`).
Windows update checking and applying go through the Windows Update COM API
(`Microsoft.Update.Session`) rather than the PSWindowsUpdate module, which would
need a PowerShell Gallery install on every host and fails on an air-gapped
fleet. `CurrentVersion` is empty on Windows and `NewVersion` carries the KB
article ID: a Windows update is not a version bump of a named package.
**The agent never reboots a host.** `ApplyUpdatesCmd` installs and stops there;
`inventory.reboot_required` reports that one is owed, set on the static snapshot
every 15 minutes. Linux fills it too, from `/var/run/reboot-required` or
`dnf needs-restarting -r`.
### Package inventory and CVE findings
Agents report their installed packages hourly; the control plane matches them
@@ -360,10 +371,147 @@ 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.
On Windows a workload is a Docker container or a Windows **service**, reported
under the same `unit` kind and the same `systemd_ok` / `systemd_error` fields —
one wire shape, worded per platform in the UI, which is the only layer that
knows the host's OS. The platform split lives entirely in the agent, as build
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
and `logs_` pairs); the control plane is OS-blind and needed no changes.
Windows collection runs PowerShell through `agent/internal/winexec`, and every
script emits JSON that a build-tag-free parser reads, so the parsers are tested
on Linux — the agent module has no Windows CI.
**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` on Linux, `VantageAgent` on Windows, 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.
@@ -481,6 +629,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
@@ -512,6 +674,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);
@@ -521,7 +684,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
@@ -578,6 +742,10 @@ vulns GET /vulnerabilities · GET /vulnerabilities/summary
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)
@@ -585,6 +753,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.
@@ -616,11 +786,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
@@ -642,6 +812,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
@@ -654,11 +825,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` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `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/`.
@@ -677,6 +848,7 @@ Notes that are not obvious from the structs:
- `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.
@@ -874,9 +1046,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
@@ -985,7 +1168,12 @@ git push origin main # server + web deploy
- **guacd for console** — protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
- **Windows agents cover the fleet-management path** — register, heartbeat, run
steps, report inventory, OS updates through the Windows Update COM API, and
workloads (services plus containers, with control and logs). They still do no
`authorized_keys` management, and no package inventory or CVE matching: the
vulnerability feeds this project uses carry no Windows data, so a Windows host
correctly reports `unsupported` rather than a clean bill of health.
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
- **Deletion lives in the control plane** — admin 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 `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
+53 -95
View File
@@ -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) {
+181
View File
@@ -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.
//
+9 -4
View File
@@ -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
View File
@@ -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})
}
+20 -39
View File
@@ -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 == "" {
+20
View File
@@ -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)
}
+4 -4
View File
@@ -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
-61
View File
@@ -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 {
+22 -7
View File
@@ -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.
+2 -2
View File
@@ -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)
+66 -29
View File
@@ -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>
);
+206 -101
View File
@@ -2,17 +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 { featureLabel } from "@/lib/features";
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);
@@ -20,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],
@@ -29,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} />;
@@ -51,121 +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"} &rarr;
</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",
// Labelled, not raw keys: this is
// the customer's own licence, and
// "vuln_scanning" is not a name
// anyone bought.
value: lic.features.map(featureLabel).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 &rarr;
</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&rsquo;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&rsquo;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>
);
}
+99 -27
View File
@@ -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&rsquo;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));
@@ -142,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) => {
@@ -159,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>;
}
@@ -308,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>
@@ -380,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();
@@ -389,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>
+51 -24
View File
@@ -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&rsquo;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>
);
}
+57 -30
View File
@@ -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>
);
}
+26 -41
View File
@@ -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>
+94 -74
View File
@@ -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>
);
}
+32 -39
View File
@@ -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 &rarr;
</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>
);
+19 -14
View File
@@ -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>
))}
+48 -43
View File
@@ -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&apos;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>
);
}
+46 -71
View File
@@ -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>
);
}
+38 -27
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+26 -1
View File
@@ -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",
+12 -12
View File
@@ -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>
);
}
+9 -25
View File
@@ -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`}>
+56 -26
View File
@@ -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>;
}
+187 -90
View File
@@ -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>
);
}
+10 -4
View File
@@ -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>
))}
+91
View File
@@ -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>
);
}
+9 -4
View File
@@ -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>
+29 -10
View File
@@ -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>
);
}
+130
View File
@@ -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)} &rarr;
</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>
);
}
+122
View File
@@ -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>;
}
+104
View File
@@ -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
View File
@@ -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"),
+56
View File
@@ -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}`;
}
+13
View File
@@ -113,6 +113,19 @@ func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
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) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+24 -31
View File
@@ -1,5 +1,3 @@
package pb
import (
@@ -83,8 +81,6 @@ type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
@@ -99,8 +95,6 @@ type ReportUpdatesRequest struct {
type ReportUpdatesResponse struct{}
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
@@ -119,20 +113,19 @@ type PartitionReport struct {
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
RebootRequired bool `json:"reboot_required,omitempty"`
}
type InventoryReportResponse struct{}
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
Type string `json:"type"`
@@ -206,6 +199,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
@@ -213,8 +210,6 @@ type ServerCommand struct {
// keepalive is not sufficient on its own.
type PingCmd struct{}
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
@@ -237,12 +232,14 @@ type GenerateKeyCmd struct {
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
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{}
@@ -258,8 +255,7 @@ type RunStepCmd struct {
Script string `json:"script"`
Env map[string]string `json:"env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
WorkspaceId string `json:"workspace_id,omitempty"`
}
@@ -278,8 +274,6 @@ type StepOutputChunk struct {
Eof bool `json:"eof,omitempty"`
}
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
@@ -302,8 +296,6 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
return m, nil
}
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
@@ -377,6 +369,7 @@ type VantageClient interface {
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)
+80
View File
@@ -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
}
+1 -1
View File
@@ -33,7 +33,7 @@ func Collect() (OSRelease, []Package, error) {
switch {
case have("dpkg-query"):
out, err := run(ctx, "dpkg-query", "-W", "-f",
`${Package}\t${Version}\t${Architecture}\t${source:Package}\n`)
`${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n`)
if err != nil {
return osrel, nil, err
}
+14 -1
View File
@@ -20,12 +20,20 @@ type Package struct {
}
// ParseDpkg reads tab-separated output of
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n'
// 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") {
@@ -36,6 +44,11 @@ func ParseDpkg(out string) []Package {
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]
+21
View File
@@ -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)
@@ -347,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) {
@@ -439,6 +450,16 @@ func runInventory(ctx context.Context, cfg *config.Config) {
r := inventory.Collect(static)
r.ServerId = cfg.ServerID
r.AgentToken = cfg.AgentToken
// Static snapshots only — every 15 minutes, not every 30 seconds. On
// Windows this spawns a PowerShell process, which is not something to
// do twice a minute forever, and a host rebooted by hand clearing the
// flag within a quarter of an hour is soon enough.
//
// Computed here rather than inside inventory.Collect so the inventory
// package gains no dependency on updates.
if static {
r.RebootRequired = updates.RebootRequired()
}
if err := client.ReportInventory(r); err != nil {
log.Printf("report inventory: %v", err)
}
+152
View File
@@ -0,0 +1,152 @@
package agentsync
import (
"context"
"log"
"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) {
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) {
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,
})
}
+14 -228
View File
@@ -1,238 +1,24 @@
package updates
import (
"bufio"
"bytes"
"context"
"os/exec"
"strings"
"time"
)
// PackageUpdate is one pending update. On Linux it is a package with a version
// on each side. On Windows CurrentVersion is empty and NewVersion carries the
// KB article ID: a Windows update is not a version bump of a named package,
// and inventing a current version would put a wrong string in front of an
// operator.
type PackageUpdate struct {
Name string
CurrentVersion string
NewVersion string
}
func detectPM() string {
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
if _, err := exec.LookPath(pm); err == nil {
if pm == "apt-get" {
return "apt"
}
return pm
}
}
return ""
}
// CheckAvailable lists pending OS updates.
func CheckAvailable() ([]PackageUpdate, error) { return checkAvailable() }
// ApplyAll installs every pending update. It never reboots: a control plane
// silently restarting a production server is unrecoverable from the UI, so the
// reboot stays a decision a person or a workflow makes. RebootRequired reports
// when one is owed.
func ApplyAll() error { return applyAll() }
func CheckAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
return checkApt()
case "dnf":
return checkDnfYum("dnf")
case "yum":
return checkDnfYum("yum")
case "pacman":
return checkPacman()
case "zypper":
return checkZypper()
case "apk":
return checkApk()
default:
return nil, nil
}
}
func ApplyAll() error {
switch detectPM() {
case "apt":
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
return err
}
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
case "dnf":
return exec.Command("dnf", "upgrade", "-y").Run()
case "yum":
return exec.Command("yum", "upgrade", "-y").Run()
case "pacman":
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
case "zypper":
return exec.Command("zypper", "update", "-y").Run()
case "apk":
return exec.Command("apk", "upgrade").Run()
default:
return nil
}
}
func checkApt() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
out, err := exec.Command("apt", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable from:") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], "/", 2)[0]
newVer := parts[1]
oldVer := ""
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
rest := line[idx+len("upgradable from: "):]
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func checkDnfYum(pm string) ([]PackageUpdate, error) {
cmd := exec.Command(pm, "check-update")
out, err := cmd.Output()
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
err = nil
}
if err != nil {
return nil, err
}
var updates []PackageUpdate
pastHeader := false
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !pastHeader {
if strings.TrimSpace(line) == "" {
pastHeader = true
}
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], ".", 2)[0]
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
}
return updates, nil
}
func checkPacman() ([]PackageUpdate, error) {
out, _ := exec.Command("pacman", "-Qu").Output()
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) < 4 {
continue
}
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
}
return updates, nil
}
func checkZypper() ([]PackageUpdate, error) {
out, err := exec.Command("zypper", "list-updates").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 5 {
continue
}
updates = append(updates, PackageUpdate{
Name: strings.TrimSpace(parts[2]),
CurrentVersion: strings.TrimSpace(parts[3]),
NewVersion: strings.TrimSpace(parts[4]),
})
}
return updates, nil
}
func checkApk() ([]PackageUpdate, error) {
out, err := exec.Command("apk", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable") {
continue
}
parts := strings.Fields(line)
if len(parts) < 1 {
continue
}
pkgVer := parts[0]
name := apkName(pkgVer)
newVer := apkVersion(pkgVer)
oldVer := ""
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
rest = strings.TrimSuffix(rest, "]")
oldVer = apkVersion(strings.TrimSpace(rest))
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func apkName(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var name []string
for _, p := range parts {
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
break
}
name = append(name, p)
}
return strings.Join(name, "-")
}
func apkVersion(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var ver []string
inVer := false
for _, p := range parts {
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
inVer = true
}
if inVer {
ver = append(ver, p)
}
}
return strings.Join(ver, "-")
}
// RebootRequired reports whether this host is waiting on a restart.
func RebootRequired() bool { return rebootRequired() }
+252
View File
@@ -0,0 +1,252 @@
package updates
import (
"bufio"
"bytes"
"context"
"os"
"os/exec"
"strings"
"time"
)
func detectPM() string {
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
if _, err := exec.LookPath(pm); err == nil {
if pm == "apt-get" {
return "apt"
}
return pm
}
}
return ""
}
func checkAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
return checkApt()
case "dnf":
return checkDnfYum("dnf")
case "yum":
return checkDnfYum("yum")
case "pacman":
return checkPacman()
case "zypper":
return checkZypper()
case "apk":
return checkApk()
default:
return nil, nil
}
}
func applyAll() error {
switch detectPM() {
case "apt":
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
return err
}
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
case "dnf":
return exec.Command("dnf", "upgrade", "-y").Run()
case "yum":
return exec.Command("yum", "upgrade", "-y").Run()
case "pacman":
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
case "zypper":
return exec.Command("zypper", "update", "-y").Run()
case "apk":
return exec.Command("apk", "upgrade").Run()
default:
return nil
}
}
// rebootRequired reads what the distributions themselves record. Debian and
// Ubuntu drop a file; the RPM family answers through needs-restarting, whose
// exit code is 1 when a reboot is owed and 0 when it is not.
func rebootRequired() bool {
if _, err := os.Stat("/var/run/reboot-required"); err == nil {
return true
}
if _, err := exec.LookPath("dnf"); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "dnf", "needs-restarting", "-r").Run(); err != nil {
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
return true
}
}
}
return false
}
func checkApt() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
out, err := exec.Command("apt", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable from:") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], "/", 2)[0]
newVer := parts[1]
oldVer := ""
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
rest := line[idx+len("upgradable from: "):]
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func checkDnfYum(pm string) ([]PackageUpdate, error) {
cmd := exec.Command(pm, "check-update")
out, err := cmd.Output()
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
err = nil
}
if err != nil {
return nil, err
}
var updates []PackageUpdate
pastHeader := false
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !pastHeader {
if strings.TrimSpace(line) == "" {
pastHeader = true
}
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], ".", 2)[0]
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
}
return updates, nil
}
func checkPacman() ([]PackageUpdate, error) {
out, _ := exec.Command("pacman", "-Qu").Output()
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) < 4 {
continue
}
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
}
return updates, nil
}
func checkZypper() ([]PackageUpdate, error) {
out, err := exec.Command("zypper", "list-updates").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 5 {
continue
}
updates = append(updates, PackageUpdate{
Name: strings.TrimSpace(parts[2]),
CurrentVersion: strings.TrimSpace(parts[3]),
NewVersion: strings.TrimSpace(parts[4]),
})
}
return updates, nil
}
func checkApk() ([]PackageUpdate, error) {
out, err := exec.Command("apk", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable") {
continue
}
parts := strings.Fields(line)
if len(parts) < 1 {
continue
}
pkgVer := parts[0]
name := apkName(pkgVer)
newVer := apkVersion(pkgVer)
oldVer := ""
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
rest = strings.TrimSuffix(rest, "]")
oldVer = apkVersion(strings.TrimSpace(rest))
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func apkName(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var name []string
for _, p := range parts {
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
break
}
name = append(name, p)
}
return strings.Join(name, "-")
}
func apkVersion(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var ver []string
inVer := false
for _, p := range parts {
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
inVer = true
}
if inVer {
ver = append(ver, p)
}
}
return strings.Join(ver, "-")
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !windows
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
// without it this file compiles on Linux too and collides with updates_linux.go.
package updates
func checkAvailable() ([]PackageUpdate, error) { return nil, nil }
func applyAll() error { return nil }
func rebootRequired() bool { return false }
+117
View File
@@ -0,0 +1,117 @@
package updates
import (
"context"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
const (
// The first search after a boot contacts Microsoft Update (or WSUS) and is
// routinely slow. Ten minutes is not generous, it is realistic.
searchTimeout = 10 * time.Minute
// A patch-Tuesday cumulative genuinely takes this long to download and
// install on a modest server.
applyTimeout = 60 * time.Minute
rebootTimeout = 2 * time.Minute
)
// The Windows Update COM API is used rather than the PSWindowsUpdate module: it
// is present on every supported Windows, needs no PowerShell Gallery install,
// and works unchanged against a WSUS server on an air-gapped fleet. The agent
// runs as LocalSystem, which holds the rights it requires.
const searchScript = `
$ErrorActionPreference = 'Stop'
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
$rows = @()
foreach ($u in $result.Updates) {
$ids = @($u.KBArticleIDs)
$kb = ''
if ($ids.Count -gt 0) { $kb = [string]$ids[0] }
$rows += [pscustomobject]@{ title = [string]$u.Title; kb = $kb }
}
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
`
const applyScript = `
$ErrorActionPreference = 'Stop'
$session = New-Object -ComObject Microsoft.Update.Session
$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0")
$batch = New-Object -ComObject Microsoft.Update.UpdateColl
foreach ($u in $result.Updates) {
if ($u.InstallationBehavior.CanRequestUserInput) { continue }
if (-not $u.EulaAccepted) {
try { $u.AcceptEula() } catch { continue }
}
$null = $batch.Add($u)
}
if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 }
$downloader = $session.CreateUpdateDownloader()
$downloader.Updates = $batch
$null = $downloader.Download()
$installer = $session.CreateUpdateInstaller()
$installer.Updates = $batch
$r = $installer.Install()
Write-Output ('resultcode=' + $r.ResultCode)
# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this
# process must exit non-zero so the agent logs a failure rather than an ack.
if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 }
exit 0
`
const rebootScript = `
$ErrorActionPreference = 'SilentlyContinue'
$si = New-Object -ComObject Microsoft.Update.SystemInfo
if ($si.RebootRequired) { Write-Output 'true'; exit 0 }
$keys = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
)
foreach ($k in $keys) { if (Test-Path $k) { Write-Output 'true'; exit 0 } }
$sm = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations
if ($sm -and $sm.PendingFileRenameOperations) { Write-Output 'true'; exit 0 }
Write-Output 'false'
`
func checkAvailable() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), searchTimeout)
defer cancel()
out, err := winexec.Run(ctx, searchScript)
if err != nil {
return nil, fmt.Errorf("windows update search: %w", err)
}
return parseUpdateSearch(out)
}
func applyAll() error {
ctx, cancel := context.WithTimeout(context.Background(), applyTimeout)
defer cancel()
if _, err := winexec.Run(ctx, applyScript); err != nil {
return fmt.Errorf("windows update install: %w", err)
}
return nil
}
func rebootRequired() bool {
ctx, cancel := context.WithTimeout(context.Background(), rebootTimeout)
defer cancel()
out, err := winexec.Run(ctx, rebootScript)
if err != nil {
return false
}
return strings.TrimSpace(out) == "true"
}
+48
View File
@@ -0,0 +1,48 @@
package updates
import (
"encoding/json"
"strings"
)
// winUpdate is one row of the Windows Update searcher's output, in the shape
// searchScript emits it.
type winUpdate struct {
Title string `json:"title"`
KB string `json:"kb"`
}
// parseUpdateSearch reads the searcher's JSON.
//
// It carries no build tag on purpose: this is the half of the Windows update
// path that can be tested on a development machine, and the agent module has no
// Windows CI.
func parseUpdateSearch(jsonText string) ([]PackageUpdate, error) {
s := strings.TrimSpace(jsonText)
if s == "" || s == "null" {
return nil, nil
}
var rows []winUpdate
if err := json.Unmarshal([]byte(s), &rows); err != nil {
// ConvertTo-Json renders a one-element array as a bare object.
var one winUpdate
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
return nil, err
}
rows = []winUpdate{one}
}
out := make([]PackageUpdate, 0, len(rows))
for _, r := range rows {
u := PackageUpdate{Name: r.Title}
if kb := strings.TrimSpace(r.KB); kb != "" {
if !strings.HasPrefix(strings.ToUpper(kb), "KB") {
kb = "KB" + kb
}
u.NewVersion = kb
}
out = append(out, u)
}
return out, nil
}
+69
View File
@@ -0,0 +1,69 @@
package updates
import "testing"
func TestParseUpdateSearchArray(t *testing.T) {
in := `[{"title":"2026-08 Cumulative Update for Windows Server 2022","kb":"5034123"},
{"title":"Windows Malicious Software Removal Tool","kb":"890830"}]`
got, err := parseUpdateSearch(in)
if err != nil {
t.Fatalf("parseUpdateSearch: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d updates, want 2", len(got))
}
if got[0].Name != "2026-08 Cumulative Update for Windows Server 2022" {
t.Errorf("Name = %q", got[0].Name)
}
if got[0].NewVersion != "KB5034123" {
t.Errorf("NewVersion = %q, want KB5034123", got[0].NewVersion)
}
if got[0].CurrentVersion != "" {
t.Errorf("CurrentVersion = %q, want empty", got[0].CurrentVersion)
}
}
// PowerShell 5.1's ConvertTo-Json collapses a one-element array into a bare
// object. A host with exactly one pending update is common, and a parser that
// only accepts arrays reports it as zero.
func TestParseUpdateSearchSingleObject(t *testing.T) {
got, err := parseUpdateSearch(`{"title":"Security Intelligence Update","kb":"2267602"}`)
if err != nil {
t.Fatalf("parseUpdateSearch: %v", err)
}
if len(got) != 1 || got[0].NewVersion != "KB2267602" {
t.Fatalf("got %+v", got)
}
}
func TestParseUpdateSearchNoKB(t *testing.T) {
got, err := parseUpdateSearch(`[{"title":"Driver update for Contoso NIC","kb":""}]`)
if err != nil {
t.Fatalf("parseUpdateSearch: %v", err)
}
if len(got) != 1 || got[0].NewVersion != "" {
t.Fatalf("got %+v, want one update with an empty NewVersion", got)
}
}
// An empty result set is "nothing pending", not a parse failure.
func TestParseUpdateSearchEmpty(t *testing.T) {
for _, in := range []string{"", " \r\n", "[]", "null"} {
got, err := parseUpdateSearch(in)
if err != nil {
t.Fatalf("parseUpdateSearch(%q): %v", in, err)
}
if len(got) != 0 {
t.Fatalf("parseUpdateSearch(%q) = %+v, want none", in, got)
}
}
}
// A KB already carrying its prefix must not become KBKB5034123.
func TestParseUpdateSearchPrefixedKB(t *testing.T) {
got, _ := parseUpdateSearch(`[{"title":"x","kb":"KB5034123"}]`)
if got[0].NewVersion != "KB5034123" {
t.Fatalf("NewVersion = %q", got[0].NewVersion)
}
}
+24
View File
@@ -0,0 +1,24 @@
// Package winexec runs PowerShell on Windows hosts.
//
// It exists because three subsystems — updates, workload collection and
// workload logs — all need the same invocation, and because getting a
// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own
// parser is a problem worth solving once.
package winexec
import (
"encoding/base64"
"unicode/utf16"
)
// EncodeCommand renders a script for powershell.exe -EncodedCommand: UTF-16LE,
// no byte-order mark, base64. This is deliberately free of build tags so it is
// tested on a Linux development machine like every other pure function here.
func EncodeCommand(script string) string {
units := utf16.Encode([]rune(script))
b := make([]byte, 0, len(units)*2)
for _, u := range units {
b = append(b, byte(u), byte(u>>8))
}
return base64.StdEncoding.EncodeToString(b)
}
+20
View File
@@ -0,0 +1,20 @@
package winexec
import "testing"
func TestEncodeCommand(t *testing.T) {
// "hi" as UTF-16LE is 68 00 69 00, which base64-encodes to aABpAA==.
if got := EncodeCommand("hi"); got != "aABpAA==" {
t.Fatalf("EncodeCommand(hi) = %q, want aABpAA==", got)
}
}
func TestEncodeCommandMultiline(t *testing.T) {
// Only that it round-trips through the same encoding PowerShell expects:
// every ASCII byte followed by a zero byte, no BOM.
got := EncodeCommand("a\nb")
want := "YQAKAGIA"
if got != want {
t.Fatalf("EncodeCommand = %q, want %q", got, want)
}
}
+31
View File
@@ -0,0 +1,31 @@
package winexec
import (
"context"
"fmt"
"os/exec"
"strings"
)
// Run executes a PowerShell script and returns its stdout.
//
// powershell.exe rather than pwsh: everything this agent runs through here
// touches Windows Update COM or CIM, both of which are most reliable under
// Windows PowerShell 5.1, and 5.1 is present on every supported Windows while
// pwsh is an optional install.
func Run(ctx context.Context, script string) (string, error) {
cmd := exec.CommandContext(ctx, "powershell.exe",
"-NoProfile", "-NonInteractive", "-EncodedCommand", EncodeCommand(script))
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
}
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("powershell: timed out")
}
return "", fmt.Errorf("powershell: %w", err)
}
return string(out), nil
}
+64
View File
@@ -0,0 +1,64 @@
package workloads
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
// ErrProtected is returned for a workload the agent will not act on.
var ErrProtected = errors.New("workload is protected")
// controlTimeout bounds a stop that may never finish on its own. `docker stop`
// waits on a container that may ignore SIGTERM, and both systemctl and
// Stop-Service block for as long as the unit's own stop timeout says. A
// timeout must return a real error rather than an ack implying success.
const controlTimeout = 90 * time.Second
// 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 isProtectedUnit(id, name)
}
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()
return controlPlatform(ctx, kind, id, action)
}
+56
View File
@@ -0,0 +1,56 @@
package workloads
import (
"context"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
)
// AgentUnit is the systemd unit this agent runs as.
const AgentUnit = "vantage-agent.service"
// 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 ""
}
func isProtectedUnit(id, name string) bool {
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
}
func controlPlatform(ctx context.Context, kind, id, action string) error {
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,74 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
// AgentUnit is the service this agent runs as — the NSSM service name written
// by installer/setup.ps1. Change one, change the other.
const AgentUnit = "VantageAgent"
// A Windows agent is never itself in a container; the Linux build reads
// /proc/self/cgroup, and there is no equivalent question to ask here.
var ownContainerID = ""
// Windows service names are case-insensitive, so the comparison must be too.
func isProtectedUnit(id, name string) bool {
return strings.EqualFold(id, AgentUnit) || strings.EqualFold(name, AgentUnit)
}
func controlPlatform(ctx context.Context, kind, id, action string) error {
switch kind {
case "container":
// Docker behaves identically on Windows, so this path is shared in
// spirit with the Linux one rather than routed through PowerShell.
cmd := exec.CommandContext(ctx, "docker", action, id)
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
case "unit":
// -Force is required: Stop-Service without it refuses outright when
// another service depends on the target, and that refusal reads to an
// operator as a silent no-op.
//
// sc.exe is avoided because it returns before the operation completes,
// which turns a timeout into a false success.
var verb string
switch action {
case "start":
verb = "Start-Service"
case "stop":
verb = "Stop-Service"
case "restart":
verb = "Restart-Service"
default:
return fmt.Errorf("unknown action %q", action)
}
script := "$ErrorActionPreference='Stop'\n" + verb + " -Name " + psQuote(id)
if action != "start" {
script += " -Force"
}
if _, err := winexec.Run(ctx, script); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
}
return fmt.Errorf("%s %s: %w", action, id, err)
}
return nil
default:
return fmt.Errorf("unknown workload kind %q", kind)
}
}
+141
View File
@@ -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()
}
+66
View File
@@ -0,0 +1,66 @@
package workloads
import (
"context"
"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()
out, err := logsPlatform(ctx, kind, id, tail)
if err != nil {
return "", false, err
}
text, truncated := capLog(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
}
+30
View File
@@ -0,0 +1,30 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strconv"
)
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
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 "", 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 "", fmt.Errorf("read logs for %s: %s", id, errText(err))
}
return string(out), nil
}
+89
View File
@@ -0,0 +1,89 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strconv"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
switch kind {
case "container":
cmd := exec.CommandContext(ctx, "docker", "logs",
"--tail", strconv.Itoa(tail), "--timestamps", id)
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
return "", fmt.Errorf("read logs for %s: %s", id, errText(err))
}
return string(out), nil
case "unit":
display := serviceDisplayName(ctx, id)
// Timestamps are formatted PowerShell-side rather than left to
// ConvertTo-Json, whose DateTime rendering differs between PowerShell
// versions — one of them emits /Date(1699...)/.
//
// $ErrorActionPreference = 'SilentlyContinue' because Get-WinEvent
// treats "no events matched" as a terminating error, and a quiet
// service is normal.
names := psQuote(id)
if display != "" && display != id {
names += "," + psQuote(display)
}
names += "," + psQuote(scmProvider)
// ProviderName includes the host-wide Service Control Manager, so a
// -MaxEvents cap of exactly tail would apply to the combined stream
// before parseEvents narrows SCM rows down to this service — on a
// host with busy service churn the target's own events could be
// squeezed out of the window entirely. Over-fetch instead, hard-capped
// so a pathological host cannot pull an unbounded batch across the
// wire, and let parseEvents trim to the last tail lines after
// filtering.
fetch := tail * 5
if fetch > 2500 {
fetch = 2500
}
script := `
$ErrorActionPreference = 'SilentlyContinue'
$rows = Get-WinEvent -FilterHashtable @{LogName='System','Application'; ProviderName=@(` + names + `)} ` +
`-MaxEvents ` + strconv.Itoa(fetch) + ` |
ForEach-Object {
[pscustomobject]@{
t = $_.TimeCreated.ToUniversalTime().ToString('o')
l = [string]$_.LevelDisplayName
p = [string]$_.ProviderName
m = [string]$_.Message
}
}
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
`
out, err := winexec.Run(ctx, script)
if err != nil {
return "", fmt.Errorf("read events for %s: %w", id, err)
}
return parseEvents(out, id, display, tail)
default:
return "", fmt.Errorf("unknown workload kind %q", kind)
}
}
// serviceDisplayName resolves a service's display name, which is what Service
// Control Manager events name it by. An empty answer is fine — the filter then
// matches on the service name alone.
func serviceDisplayName(ctx context.Context, id string) string {
out, err := winexec.Run(ctx,
"$ErrorActionPreference='SilentlyContinue'\n"+
"(Get-Service -Name "+psQuote(id)+").DisplayName")
if err != nil {
return ""
}
return trimLine(out)
}
@@ -0,0 +1,42 @@
package workloads
import (
"context"
"os"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
)
const servicesTimeout = 60 * time.Second
const servicesScript = `
$ErrorActionPreference = 'Stop'
$svcs = Get-CimInstance Win32_Service |
Select-Object Name,DisplayName,State,StartMode,PathName,ExitCode
ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress
`
// collectUnits enumerates Windows services. The bool and string it returns are
// the same SystemdOK / SystemdError pair the Linux collector fills: the wire
// shape is shared, and the UI words it per platform.
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
ctx, cancel := context.WithTimeout(ctx, servicesTimeout)
defer cancel()
out, err := winexec.Run(ctx, servicesScript)
if err != nil {
return nil, false, "Win32_Service query failed: " + err.Error()
}
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = `C:\Windows`
}
wls, err := parseServices(out, systemRoot)
if err != nil {
return nil, false, "Win32_Service output could not be read: " + err.Error()
}
return wls, true, ""
}
+94
View File
@@ -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"}
// collectUnits 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 collectUnits(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
}
+22
View File
@@ -0,0 +1,22 @@
//go:build !linux && !windows
// The build constraint is load-bearing — see updates_other.go.
package workloads
import (
"context"
"fmt"
)
var ownContainerID = ""
func collectUnits(context.Context) ([]Workload, bool, string) { return nil, false, "" }
func isProtectedUnit(string, string) bool { return false }
func controlPlatform(context.Context, string, string, string) error {
return fmt.Errorf("workload control is not supported on this platform")
}
func logsPlatform(context.Context, string, string, int) (string, error) {
return "", fmt.Errorf("workload logs are not supported on this platform")
}
+216
View File
@@ -0,0 +1,216 @@
package workloads
import (
"encoding/json"
"strings"
)
// winService is one row of Get-CimInstance Win32_Service.
//
// Win32_Service rather than Get-Service: Get-Service exposes neither PathName
// nor StartMode, and the filter below needs both.
type winService struct {
Name string `json:"Name"`
DisplayName string `json:"DisplayName"`
State string `json:"State"`
StartMode string `json:"StartMode"`
PathName string `json:"PathName"`
ExitCode int `json:"ExitCode"`
}
// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service
// carrying it has not failed — it has not run since boot — and painting that
// red would cry wolf on every host.
const exitCodeNeverStarted = 1077
// servicePath extracts the executable from a Win32_Service PathName.
//
// A naive split on whitespace misfiles a substantial share of a real fleet:
// `"C:\Program Files\X\x.exe" -service` is one path and one argument.
func servicePath(pathName string) string {
s := strings.TrimSpace(pathName)
if s == "" {
return ""
}
if s[0] == '"' {
if end := strings.IndexByte(s[1:], '"'); end >= 0 {
return s[1 : 1+end]
}
// No closing quote: a malformed or truncated PathName. Fall back to
// the unquoted handling below on the text after the opening quote,
// so this yields a bare path rather than a path plus trailing
// argument text.
s = s[1:]
}
if i := exeBoundaryIndex(s); i >= 0 {
return s[:i+len(".exe")]
}
if i := strings.IndexAny(s, " \t"); i >= 0 {
return s[:i]
}
return s
}
// exeBoundaryIndex finds the first ".exe" (case-insensitive) in s that
// actually ends the executable name — followed by end-of-string, whitespace,
// or a double quote — rather than continuing into a longer segment such as
// ".exec". It returns -1 when no such occurrence exists, so a path like
// `C:\Program Files\Ad.exec\tool.com -flag` is not misparsed by matching the
// ".exe" inside "Ad.exec" and silently dropping the real filename.
func exeBoundaryIndex(s string) int {
lower := strings.ToLower(s)
from := 0
for {
rel := strings.Index(lower[from:], ".exe")
if rel < 0 {
return -1
}
idx := from + rel
end := idx + len(".exe")
if end == len(s) || s[end] == ' ' || s[end] == '\t' || s[end] == '"' {
return idx
}
from = idx + 1
}
}
// parseServices turns the collector's JSON into workloads.
//
// systemRoot is a parameter rather than an environment read so this is testable
// off Windows. The caller passes %SystemRoot%.
//
// The filter mirrors the systemd collector's intent: show what an operator
// installed, and show what is meant to be up but is not. Services under
// %SystemRoot%\System32 are the platform's own, and a typical host has well
// over a hundred of them.
func parseServices(jsonText, systemRoot string) ([]Workload, error) {
s := strings.TrimSpace(jsonText)
if s == "" || s == "null" {
return nil, nil
}
var rows []winService
if err := json.Unmarshal([]byte(s), &rows); err != nil {
var one winService
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
return nil, err
}
rows = []winService{one}
}
sys32 := strings.ToLower(strings.TrimRight(systemRoot, `\`) + `\system32\`)
var wls []Workload
for _, r := range rows {
if p := strings.ToLower(servicePath(r.PathName)); p != "" && strings.HasPrefix(p, sys32) {
continue
}
running := strings.EqualFold(r.State, "Running")
failed := !running && r.ExitCode != 0 && r.ExitCode != exitCodeNeverStarted
auto := strings.HasPrefix(strings.ToLower(r.StartMode), "auto")
if !running && !failed && !auto {
continue
}
state := "stopped"
switch {
case running:
state = "running"
case failed:
state = "failed"
}
name := r.DisplayName
if name == "" {
name = r.Name
}
wls = append(wls, Workload{
Kind: "unit",
ID: r.Name,
Name: name,
State: state,
})
}
return wls, nil
}
// psQuote renders a Go string as a PowerShell single-quoted literal. Single
// quotes suppress every form of expansion, so the only character needing an
// escape is the quote itself, which is doubled.
func psQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// scmProvider is the provider every service's start and stop is logged under,
// host-wide.
const scmProvider = "Service Control Manager"
type winEvent struct {
T string `json:"t"`
L string `json:"l"`
P string `json:"p"`
M string `json:"m"`
}
// parseEvents renders Get-WinEvent output as text in the shape journalctl
// --output=short-iso produces, so the log dialog needs no per-platform
// rendering: "<timestamp> <level> <message>", oldest first.
//
// The caller over-fetches from Get-WinEvent because the ProviderName filter
// includes the host-wide Service Control Manager, and a -MaxEvents cap
// applied before SCM rows are narrowed down to this service would squeeze the
// target's own events out of the window on a host with busy service churn.
// tail is therefore applied here, AFTER filtering and AFTER the oldest-first
// reversal, keeping the last tail lines — the most recent lines are the ones
// worth keeping, matching capLog's front-trim reasoning in the shared
// logs.go.
func parseEvents(jsonText, serviceName, displayName string, tail int) (string, error) {
s := strings.TrimSpace(jsonText)
if s == "" || s == "null" {
return "", nil
}
var rows []winEvent
if err := json.Unmarshal([]byte(s), &rows); err != nil {
var one winEvent
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
return "", err
}
rows = []winEvent{one}
}
var lines []string
for _, e := range rows {
if strings.EqualFold(e.P, scmProvider) {
if !strings.Contains(e.M, serviceName) &&
(displayName == "" || !strings.Contains(e.M, displayName)) {
continue
}
}
msg := strings.TrimSpace(strings.ReplaceAll(e.M, "\r\n", " "))
lines = append(lines, e.T+" "+e.L+" "+msg)
}
// Get-WinEvent is newest-first. Reverse it.
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
lines[i], lines[j] = lines[j], lines[i]
}
if tail > 0 && len(lines) > tail {
lines = lines[len(lines)-tail:]
}
return strings.Join(lines, "\n"), nil
}
// trimLine reduces single-value PowerShell output to its first non-empty line.
func trimLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if t := strings.TrimSpace(l); t != "" {
return t
}
}
return ""
}
+188
View File
@@ -0,0 +1,188 @@
package workloads
import (
"strings"
"testing"
)
func TestServicePath(t *testing.T) {
cases := []struct{ in, want string }{
{`"C:\Program Files\Contoso\svc.exe" -service`, `C:\Program Files\Contoso\svc.exe`},
{`C:\WINDOWS\system32\svchost.exe -k netsvcs`, `C:\WINDOWS\system32\svchost.exe`},
{`C:\Vantage\vantage-agent.exe`, `C:\Vantage\vantage-agent.exe`},
{`"C:\no\args.exe"`, `C:\no\args.exe`},
{``, ``},
// ".exe" appearing inside an earlier segment ("Ad.exec") must not be
// treated as the end of the executable — that would drop the real
// filename and arguments.
{`C:\Program Files\Ad.exec\tool.com -flag`, `C:\Program`},
// An unterminated quote falls back to the unquoted handling on the
// text after the opening quote, yielding a bare path rather than a
// path plus trailing argument text.
{`"C:\Program Files\Contoso\svc.exe -service`, `C:\Program Files\Contoso\svc.exe`},
}
for _, c := range cases {
if got := servicePath(c.in); got != c.want {
t.Errorf("servicePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseServicesFilters(t *testing.T) {
in := `[
{"Name":"Contoso","DisplayName":"Contoso Broker","State":"Running","StartMode":"Auto","PathName":"\"C:\\Program Files\\Contoso\\svc.exe\" -service","ExitCode":0},
{"Name":"Themes","DisplayName":"Themes","State":"Running","StartMode":"Auto","PathName":"C:\\WINDOWS\\system32\\svchost.exe -k netsvcs","ExitCode":0},
{"Name":"Fabrikam","DisplayName":"Fabrikam Sync","State":"Stopped","StartMode":"Auto","PathName":"C:\\Fabrikam\\sync.exe","ExitCode":0},
{"Name":"Northwind","DisplayName":"Northwind Poller","State":"Stopped","StartMode":"Manual","PathName":"C:\\Northwind\\poll.exe","ExitCode":0},
{"Name":"Crashed","DisplayName":"Crashed Thing","State":"Stopped","StartMode":"Auto","PathName":"C:\\Crashed\\c.exe","ExitCode":1067}
]`
got, err := parseServices(in, `C:\WINDOWS`)
if err != nil {
t.Fatalf("parseServices: %v", err)
}
byID := map[string]Workload{}
for _, w := range got {
byID[w.ID] = w
}
// The OS's own svchost service is dropped; a manual, stopped, never-failed
// service is nobody's business either.
if _, ok := byID["Themes"]; ok {
t.Error("Themes (under %SystemRoot%) should be filtered out")
}
if _, ok := byID["Northwind"]; ok {
t.Error("stopped Manual service should be filtered out")
}
if len(got) != 3 {
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
}
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "running" {
t.Errorf("Contoso = %+v", w)
}
// Enabled but not running is exactly the row worth seeing.
if byID["Fabrikam"].State != "stopped" {
t.Errorf("Fabrikam state = %q, want stopped", byID["Fabrikam"].State)
}
// A non-zero exit code on a stopped service is a crash, not a clean stop.
if byID["Crashed"].State != "failed" {
t.Errorf("Crashed state = %q, want failed", byID["Crashed"].State)
}
}
// 1077 means "no attempt to start since boot" — a clean stopped service, not a
// failure, and reporting it red would cry wolf on every host.
func TestParseServicesExitCode1077(t *testing.T) {
in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]`
got, err := parseServices(in, `C:\WINDOWS`)
if err != nil {
t.Fatalf("parseServices: %v", err)
}
if len(got) != 1 || got[0].State != "stopped" {
t.Fatalf("got %+v, want one stopped workload", got)
}
}
func TestParseServicesSingleObjectAndEmpty(t *testing.T) {
one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}`
got, err := parseServices(one, `C:\WINDOWS`)
if err != nil || len(got) != 1 {
t.Fatalf("single object: got %+v, err %v", got, err)
}
for _, in := range []string{"", "[]", "null"} {
got, err := parseServices(in, `C:\WINDOWS`)
if err != nil || len(got) != 0 {
t.Fatalf("parseServices(%q) = %+v, err %v", in, got, err)
}
}
}
func TestPSQuote(t *testing.T) {
if got := psQuote(`it's`); got != `'it''s'` {
t.Fatalf("psQuote = %s", got)
}
if got := psQuote(`plain`); got != `'plain'` {
t.Fatalf("psQuote = %s", got)
}
}
func TestParseEventsFormatsAndOrders(t *testing.T) {
// Get-WinEvent returns newest first; journalctl --output=short-iso returns
// oldest first, and the log dialog and capLog's front-trim both assume the
// most recent line is at the bottom.
in := `[
{"t":"2026-08-13T10:22:31.0000000Z","l":"Error","p":"Contoso","m":"broker died"},
{"t":"2026-08-13T10:22:03.0000000Z","l":"Information","p":"Contoso","m":"broker starting"}
]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
want := "2026-08-13T10:22:03.0000000Z Information broker starting\n" +
"2026-08-13T10:22:31.0000000Z Error broker died"
if got != want {
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
}
}
// Service Control Manager logs every service on the host under one provider, so
// its rows must be filtered down to the target or the log is somebody else's.
func TestParseEventsFiltersOtherServicesSCM(t *testing.T) {
in := `[
{"t":"2026-08-13T10:00:00Z","l":"Information","p":"Service Control Manager","m":"The Print Spooler service entered the running state."},
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Service Control Manager","m":"The Contoso Broker service entered the running state."}
]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
if strings.Contains(got, "Print Spooler") {
t.Errorf("another service's SCM event leaked in:\n%s", got)
}
if !strings.Contains(got, "Contoso Broker") {
t.Errorf("the target's SCM event was dropped:\n%s", got)
}
}
// A service that has logged nothing is normal. An error there would read as a
// broken feature.
func TestParseEventsEmpty(t *testing.T) {
for _, in := range []string{"", "[]", "null"} {
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil || got != "" {
t.Fatalf("parseEvents(%q) = %q, err %v", in, got, err)
}
}
}
// The over-fetch in logs_windows.go can return more events than the caller
// asked for once SCM rows are filtered down to the target; parseEvents must
// keep the most RECENT tail lines, not the oldest, matching capLog's
// front-trim reasoning in the shared logs.go.
func TestParseEventsTrimsToTailKeepingMostRecent(t *testing.T) {
in := `[
{"t":"2026-08-13T10:00:06Z","l":"Information","p":"Contoso","m":"event 6"},
{"t":"2026-08-13T10:00:05Z","l":"Information","p":"Contoso","m":"event 5"},
{"t":"2026-08-13T10:00:04Z","l":"Information","p":"Contoso","m":"event 4"},
{"t":"2026-08-13T10:00:03Z","l":"Information","p":"Contoso","m":"event 3"},
{"t":"2026-08-13T10:00:02Z","l":"Information","p":"Contoso","m":"event 2"},
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Contoso","m":"event 1"}
]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 2)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
want := "2026-08-13T10:00:05Z Information event 5\n" +
"2026-08-13T10:00:06Z Information event 6"
if got != want {
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
}
}
+60
View File
@@ -0,0 +1,60 @@
package workloads
import (
"context"
"crypto/sha256"
"encoding/hex"
"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: containers from Docker, and
// units from systemd on Linux or the service control manager on Windows.
func Collect(ctx context.Context) Result {
var r Result
containers, dockerOK, dockerErr := collectDocker(ctx)
units, systemdOK, systemdErr := collectUnits(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))
}
-30
View File
@@ -1,30 +0,0 @@
# Current cloud instance process
The current processs for creating cloud instances is incorrect.
At the moment the cloud instance process is the following:
- Customer goes to `https://vantage.hostxtra.co.uk/start` then fills in the form.
- Customer is then sent and email to verify
- Customer clicks the link and the instance is created in the DB.
- Customer can then access the instance.
As the `/start` process is auto creating a new instance this should default to the free tier instance.
The issue is that this doesn't create an `account` and `admin_instance` on the admin side.
The Cloud instance creation / account creation needs to be restructured.
for context when I say `hq` I mean `admin`
- customer goes to `https://vantage.hostxtra.co.uk/start` and fills in the form.
- This is where the HQ account is created.
- The customer is then sent and email to verify their email address.
- The customer can then access the HQ customer portal.
- The customer can then create a free new instance in the HQ portal.
- The cloud instance is created in the DB.
- The HQ `account` and `admin_instance` is created and populated in the DB.
- A `Free` License is created and attached to the instance.
- The customer is then sent an email letting them know the instance has been created and when the license expires.
- The customer will need to renew the license after expiry, if they are on a Free license.
- This is so that unused instances can be cleaned up if no renew after a length of time has passed.
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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
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
@@ -1,307 +0,0 @@
# Multiple auth providers
Date: 2026-08-03
## Problem
An instance can configure exactly one OIDC provider. `instance_oidc` holds one
document per instance, `/auth/oidc/start` takes no argument, and `/login`
renders an unconditional "Sign in with your instance's SSO" button whether or
not anything is configured behind it. Customers who federate with more than one
identity source cannot, and customers who federate with none are shown a button
that leads to an error.
## Goals
- N auth providers per instance, each independently enabled and named.
- Login page renders one button per enabled provider, and none when there are
none.
- Local email/password login can be turned off per instance.
- Presets for the common identity providers, so a customer supplies a tenant ID
rather than an issuer URL.
- Existing configured SSO keeps working across the upgrade with no customer
action.
## Non-goals
- SAML. Different protocol, metadata parsing and certificate handling; not in
this work.
- Per-provider role or group mapping. Provisioned users remain `member`, as
today.
- Provider-specific account linking. An email address is an email address; the
existing instance-scoped lookup stands.
## Data model
New collection `auth_providers`, one document per provider:
```go
type AuthProvider struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
ProviderID string `bson:"provider_id" json:"provider_id"`
Name string `bson:"name" json:"name"`
Kind string `bson:"kind" json:"kind"` // "oidc" | "oauth2"
Preset string `bson:"preset" json:"preset"` // "" for custom
Issuer string `bson:"issuer" json:"issuer"`
ClientID string `bson:"client_id" json:"client_id"`
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
Scopes []string `bson:"scopes" json:"scopes"`
Enabled bool `bson:"enabled" json:"enabled"`
CallbackNotice bool `bson:"callback_notice" json:"callback_notice"`
Order int `bson:"order" json:"order"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
```
`ProviderID` 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.
Unique index on `(instance_id, provider_id)`. Index build is fatal on failure,
matching `EnsureAuthIndexes` — a duplicate `provider_id` within an instance
would make the callback ambiguous.
`ClientSecretEnc` is AES-256-GCM under `KEY_ENCRYPTION_KEY`, as
`instance_oidc.client_secret_enc` is today, and is never serialised.
### Presets
A Go table in `server/internal/auth/presets.go`, not database rows — adding one
is a commit, not a migration.
| Preset | Kind | Issuer | Input asked of the customer | Default scopes |
| ------- | -------- | ------------------------------------------------- | --------------------------- | ----------------------------- |
| `entra` | `oidc` | `https://login.microsoftonline.com/{tenant}/v2.0` | Directory (tenant) ID | `openid profile email` |
| `google`| `oidc` | `https://accounts.google.com` | none | `openid profile email` |
| `okta` | `oidc` | `https://{domain}/oauth2/default` | Okta org domain | `openid profile email` |
| `github`| `oauth2` | n/a | none | `read:user user:email` |
| `` (custom) | `oidc` | supplied verbatim | Issuer URL | `openid profile email` |
The issuer template is expanded server-side on save; the stored `Issuer` is
always the resolved URL, so nothing downstream has to know a preset existed.
### Settings
`settings.local_login_enabled bool`, defaulting true. Absent on existing
documents, and Go's zero value for `bool` is false, so the field is read through
a `*bool` and a nil pointer means enabled. A plain `bool` would silently
disable password login on every instance in the fleet at upgrade.
## Migration
`0005_auth_providers` — the next free number; `0004_instance_rename` is the
highest recorded today. For each document in `instance_oidc`, insert one
`auth_providers` document:
- `Name: "Single sign-on"`
- `Preset: ""`, `Kind: "oidc"`
- `Issuer`, `ClientID`, `Enabled` copied
- `ClientSecretEnc` copied **verbatim**, not decrypted and re-encrypted — a
migration that needs `KEY_ENCRYPTION_KEY` fails on an instance that has none
and strands the SSO configuration.
- `Scopes: ["openid", "profile", "email"]`, matching what `oidc.go` hardcodes
today.
- `ProviderID` freshly generated.
- `CallbackNotice: true` — this provider's redirect URI has changed and an
administrator has not yet acknowledged it. Set only by the migration; cleared
by the settings UI. New providers are created `false`.
`instance_oidc` is left in place and no longer read. Idempotent by skipping any
instance that already has an `auth_providers` document, so a re-run after a
partial failure completes rather than duplicating.
## Auth flow
Routes:
```
GET /auth/oidc/:providerId/start
GET /auth/oidc/:providerId/callback
```
The old unparameterised `/auth/oidc/start` and `/auth/oidc/callback` are
**removed**, not retained. See Upgrade impact below — this breaks configured SSO
until the customer updates their IdP, and that is accepted deliberately rather
than carried as a compatibility path.
The state token in Redis stores `{instance_id, provider_id}` rather than the
bare instance ID. The callback resolves its provider from the consumed state
and cross-checks it against `:providerId` in the path, refusing a mismatch —
the path alone is attacker-controlled, and the state is the half that was
issued by the start handler.
`providerForInstance` becomes `providerFor(ctx, c, instanceID, providerID)`.
The `go-oidc` provider cache keys on `provider_id`, not instance. Saving,
disabling or deleting a provider evicts that key.
`redirectURL(c, providerID)` returns the one per-provider shape, and returns the
same URL in the start and callback halves of a flow — an IdP rejects the token
exchange if they differ.
### OIDC providers
Unchanged from the current implementation: `AuthCodeURL` with the stored
scopes, exchange, `id_token` verified against the provider's key set with
`ClientID` as audience, `email` and `name` claims extracted.
### GitHub (`kind: "oauth2"`)
GitHub is OAuth2 and issues no `id_token`, so it takes a separate branch:
exchange the code, then `GET https://api.github.com/user/emails` with the access
token and take the address that is both `primary` and `verified`. An
unverified-only response is refused — an unverified address is not proof of
control, and accepting one would let anyone holding a GitHub account claim any
address in the instance. `name` comes from `GET https://api.github.com/user`.
Both branches converge on one function:
```go
func completeSSOLogin(c *gin.Context, instanceID, email, name string) error
```
which holds today's lookup-or-provision, session creation, `TouchLastLogin` and
cookie set, verbatim. Email is lower-cased before lookup, and the lookup stays
`GetUserInInstanceByEmail` — instance-scoped, as it is now.
### Licence gate
`services.GetLicenseState(instanceID).Feature("oidc")` continues to gate both
the start and the callback, for every provider kind, and is checked on the
callback against the instance named by the consumed state rather than the host.
Unchanged behaviour, applied to more providers.
## REST API
Unauthenticated:
```
GET /auth/providers
-> {"local_enabled": true,
"providers": [{"id": "...", "name": "...", "preset": "entra"}]}
```
Instance is resolved from the host, as `/auth/bootstrap-status` already does.
The response carries **no issuer, no client ID and no secret** — it is served to
anyone who can reach the login page.
Session-authed, `owner|admin`, under `/api`:
```
GET,POST /auth/providers
PUT,DELETE /auth/providers/:id
POST /auth/providers/:id/test
```
`test` fetches the provider's discovery document (or, for GitHub, calls the API
with the stored credentials) and reports reachability. It does not sign anyone
in.
`GET,PUT /api/org/oidc` is removed along with the old auth routes. Its only
caller is `OIDCCard.tsx`, which this work replaces, and a compatibility shim
over a one-of-many model would have to invent which provider it means.
Every mutation writes an audit event, as every mutating path does.
### Lockout guards
Both refused with 409 and a distinct error code:
- `local_login_required` — disabling local login while zero providers are
enabled.
- `last_provider` — disabling or deleting the last enabled provider while local
login is off.
These are enforced in the service layer, not the handler, so the two endpoints
that can reach the condition cannot disagree.
## Frontend
### Settings
`web/components/settings/OIDCCard.tsx` becomes `AuthProvidersCard`, in the
Access group of `/settings` where the OIDC card already lives. It renders the
provider list with per-row enable toggle, edit, delete and drag ordering, an
Add flow that asks for the preset first and then only the fields that preset
needs, and the local-login toggle beneath the list. A guard violation surfaces
the 409's message rather than a generic failure.
Every provider row shows its **callback URL** with click-to-copy — that is the
value the customer pastes into their IdP, it now differs per provider, and after
the upgrade every migrated provider needs it re-pasted. A migrated provider
additionally carries a warning until an administrator dismisses it, naming the
change and the URL. Dismissal is per provider, stored on the document.
### Login page
`web/app/login/page.tsx` calls `/auth/providers` on mount alongside the existing
`bootstrapStatus` call, and renders on the result:
| `local_enabled` | providers | Rendered |
| --------------- | --------- | --------------------------------------------------- |
| true | none | Password form only. No divider, no buttons. |
| true | some | Password form, divider, one button per provider. |
| false | some | Buttons only. No form, no divider. |
| false | none | Password form (see below). |
The last row cannot be reached through the API — the guards above prevent it —
but a hand-edited database could produce it, and a login page that renders
nothing at all is unrecoverable without database access. It therefore falls back
to the password form.
The current unconditional SSO button and its "SSO must be enabled for this
instance by an administrator" note are both removed; the button now only exists
when it works.
Buttons are labelled with the provider's `Name` and carry the preset's icon
where there is one, a neutral key glyph otherwise. Presets never override the
name — a customer who calls their Entra provider "Staff" gets "Staff".
Errors keep the existing `/login?error=<code>` redirect convention.
## Testing
- Migration: an `instance_oidc` document produces one enabled provider with the
ciphertext byte-identical; a re-run inserts nothing further.
- `local_login_enabled` absent decodes as enabled.
- Guards: both 409 paths, and the enable/disable sequences that approach them
without crossing.
- Per-provider callback: two providers in one instance, each resolving to its
own configuration; a `provider_id` from another instance answers 404.
- A callback whose `:providerId` disagrees with the consumed state is refused,
and the state is consumed rather than left replayable.
- The removed routes (`/auth/oidc/start`, `/auth/oidc/callback`,
`/api/org/oidc`) answer 404.
- GitHub: primary+verified selected; verified-only-absent refused.
- `/auth/providers` response contains no issuer, client ID or secret.
## Upgrade impact
**This release breaks configured SSO until each customer updates their identity
provider.** The old `/auth/oidc/callback` is gone, migrated providers are
reachable only at `/auth/oidc/<providerId>/callback`, and an IdP still pointing
at the old URL fails the flow.
It is a deliberate trade: one callback shape rather than two, no
`legacy_callback` branch through `redirectURL`, and no permanently retained
route whose only purpose is a single past upgrade.
Mitigations, in order of who sees them first:
- The settings card shows the new callback URL per provider with click-to-copy,
and a migrated provider carries a dismissable warning naming the change.
- The failure is visible rather than silent: an IdP rejects the redirect URI
before Vantage is reached, so the customer sees their own provider's error.
- Local password login is unaffected, so no instance is locked out — an
administrator can always sign in to fix the URL. This is why
`local_login_enabled` defaults to true and why nothing in this migration
turns it off.
- Release notes and `docsite/docs/vantage/settings.md` state the required
action.
## Deployment notes
No new environment variables. No agent change. `KEY_ENCRYPTION_KEY` is already
required wherever OIDC was configured, and the migration does not add a
dependency on it.
@@ -1,235 +0,0 @@
# Server tags and scheduled workflows
Date: 2026-08-04
Two features, designed together because the second is worth much less without
the first. Tags make a target set describable; schedules make it recur. A
nightly job that patches "everything tagged `env:staging`" needs both halves,
and neither half is large on its own.
---
## Part A — Server tags
### Model
`models.Server` gains one field:
```go
Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
```
Keys and values are lowercase `[a-z0-9_-]`. Keys are capped at 32 characters,
values at 64, and a server holds at most 20 tags. Validation lives in the
service layer rather than the handler, so the tag endpoint, the server-create
path and anything added later cannot disagree about what a valid tag is.
There is **no `tags` collection.** A tag is a property of a server, not an
entity with a lifecycle: a registry would need reference counting to know when
a tag stopped existing, and garbage collection to act on it, which is work
bought for nothing. The list of known keys and values that the UI offers for
autocomplete is a distinct aggregation over `servers`, cached for 60 seconds —
the same treatment org lookups already get.
No reserved keys ship in this change. If inventory-derived tags (`os`, `arch`)
are added later they take a `sys:` key prefix, so a user tag written today can
never collide with a system tag invented tomorrow.
Index: `{instance_id: 1, "tags.$**": 1}` — a wildcard index over the tag
subdocument, because the queried key is chosen by the user at request time and
cannot be named in advance.
### API
```
PUT /api/servers/:id/tags # replace the whole map
GET /api/servers/tags # known keys and values, for pickers
GET /api/servers?tag=env:prod # repeatable; AND across keys
```
`PUT` replaces the entire map rather than patching one tag. A tag set is small
enough that sending all of it is free, and last-write-wins over a whole map is
easier to reason about than merge semantics between two people editing the same
server. The audit event records the map before and after.
`?tag=` is repeatable and ANDs: `?tag=env:prod&tag=role:web` matches servers
carrying both. A malformed value (no colon, unknown characters) is a 400 rather
than a silent empty result — a filter that matches nothing and a filter that is
nonsense look identical in a list, and only one of them is the user's fault.
### Targeting
`models.Workflow` gains `TargetTags map[string]string` beside the existing
`TargetServerIDs`. One function in `services` resolves them:
```go
ResolveTargets(ctx, instanceID string, ids []string, tags map[string]string) ([]Server, error)
```
- Result is the **distinct union** of the explicit IDs and the tag matches.
- Tag matching ANDs across keys.
- Offline servers are included. The dispatcher already answers 503 per server,
and a patch run that silently omits an unreachable machine is worse than one
that visibly fails on it.
- Empty IDs **and** empty tags returns `ErrNoTargets` (400). A workflow that
matches nothing must say so rather than report success over zero servers.
The resolved set is snapshotted into `WorkflowRun.ServerRuns` exactly as today.
History records what actually ran, not what the selector would match when the
run is later read back — the same reason `steps_snapshot` exists.
### Frontend
- **Server detail**: tag chips in the header with an inline editor. Keys
autocomplete from `GET /api/servers/tags`, values autocomplete per key.
- **`/servers`**: a filter bar that reads and writes the same `?tag=` query
params the API takes, so a filtered fleet view is a URL someone can send.
- **Workflow designer**: a target section holding both inputs, with a live
"runs on 14 servers" readout that lists them on hover. The union model costs
us the at-a-glance answer to "what will this touch"; this readout buys it
back, and it is the reason the union is acceptable.
---
## Part B — Scheduled workflows
### Model
```go
type Schedule struct {
Enabled bool `bson:"enabled" json:"enabled"`
Cron string `bson:"cron" json:"cron"` // 5-field
TZ string `bson:"tz" json:"tz"` // IANA name
}
type Skip struct {
Reason string `bson:"reason" json:"reason"` // "missed" | "already_running"
Due time.Time `bson:"due" json:"due"`
At time.Time `bson:"at" json:"at"`
}
```
On `Workflow`:
```go
Schedule *Schedule `bson:"schedule,omitempty"`
NextRunAt *time.Time `bson:"next_run_at,omitempty"` // UTC, indexed
LastRunAt *time.Time `bson:"last_run_at,omitempty"`
LastSkipped *Skip `bson:"last_skipped,omitempty"`
```
`next_run_at` is **persisted, not held in memory.** A leader handover between
computing the next occurrence and firing it would otherwise either lose the
occurrence or fire it twice. Coordination state has to live where every replica
can see it — the same argument that put `workflow_log_seq` in MongoDB.
Cron parsing uses `robfig/cron/v3`'s **parser only**`Parse` and
`Next(time)`. Its scheduler and goroutines are not used; the loop below is ours
and has to be, because it runs under the leader lock.
**Alpine ships no tzdata.** `server/Dockerfile` builds a slim image, so
`time.LoadLocation("Europe/London")` returns an error and every schedule
falls back to UTC — an hour wrong for half the year, in the direction nobody
notices until a maintenance window lands in business hours. `main` therefore
imports `_ "time/tzdata"`, embedding the database in the binary. Zone names are
also validated at save time, so an unknown zone is a 400 rather than a surprise
at 2am.
### Scheduler
A new `server/internal/workflowsched` package, started inside the **existing**
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched`, `StartReaper`
and the sweepers. One role, one lock. It takes the same cancellable context and
returns the instant leadership is lost.
The loop ticks every 30 seconds:
1. `find({schedule.enabled: true, next_run_at: {$lte: now}})`.
2. **Claim atomically.** `findOneAndUpdate` matching the document *and* its
current `next_run_at`, setting the recomputed next occurrence. A process
that reaches the same document after another has claimed it matches nothing
and does nothing. The claim is what makes this correct; the leader lock only
makes it cheap.
3. **Grace check.** If `now - due > 1h`, record
`last_skipped{reason: "missed"}`, write an audit event, and do not run. A
job missed by ten minutes during a deploy should still run; one missed by
two days should not fire at lunchtime.
4. **Overlap check.** If a run for this workflow is still active, record
`last_skipped{reason: "already_running"}`, audit, and do not run. A patch
workflow must never run twice at once, and a silent skip is how a week goes
by before anyone notices nothing ran.
5. Otherwise start the run through the **same** `RunWorkflow` path a person
uses, with `TriggeredBy: "schedule"`.
Step 5 is the design. A scheduled run is an ordinary run with a different
trigger: no second dispatch path, no second snapshot format, and the run detail
page needs no changes to display one.
### API
```
PUT /api/workflows/:id/schedule # {enabled, cron, tz}
GET /api/workflows/:id/schedule/preview?cron=…&tz=… # next 3 occurrences
```
`PUT` validates the expression and the zone, then computes and stores
`next_run_at`. The preview endpoint exists so the browser and the scheduler
agree on what a cron string means — a client-side cron parser that disagrees
with the server by one field is a bug found in production, at night.
### Frontend
- **Workflow page**: a schedule card with preset buttons (hourly, nightly at
HH:MM, weekly on DAY at HH:MM) that write cron underneath, a raw cron field
for anything else, a timezone select, and the next three occurrences rendered
from the preview endpoint in mono.
- **Workflows list**: a schedule chip and the next run as relative time.
- **Skips are surfaced**, not just stored: a warning line reading
"Skipped Sun 02:00 — previous run still active". Recording a reason nobody
reads is the same as not recording one.
---
## Out of scope
**Notification on scheduled-run failure.** It needs the monitor channel
machinery pointed at workflow outcomes and its own answer to what counts as
failure — a non-zero exit on a step with `on_failure: continue` is not
obviously an alert. Visibility in this change is the run list and the recorded
skip reason. Excluded deliberately, not overlooked.
**Tag-scoped permissions.** Roles stay instance-wide. Tags describe servers;
they do not yet gate who may act on them.
**Inventory-derived tags.** Reserved via the `sys:` prefix, not implemented.
---
## Migration and compatibility
No migration is required. `Tags`, `TargetTags` and `Schedule` are all
`omitempty` and absent means what it meant before: no tags, no selector, no
schedule. Existing workflows keep their explicit server lists and behave
identically.
The wildcard tag index and the `next_run_at` index are declared by a new
`EnsureServerIndexes`, following the convention `EnsureSecretIndexes` and
`EnsureWorkflowIndexes` already set: it warns rather than aborting boot,
because a missing index degrades
tag filtering to a collection scan on a small collection rather than breaking
the fleet list.
## Testing
- `ResolveTargets`: union deduplicates; AND across tag keys; empty/empty
returns `ErrNoTargets`; offline servers are included.
- Tag validation: charset, length caps, tag count cap, malformed `?tag=` is a
400.
- Schedule validation: bad cron and unknown zone both 400; `next_run_at` is
computed in the stored zone, verified across a DST boundary.
- Scheduler claim: two concurrent claims of the same due workflow start exactly
one run.
- Grace window: due 10 minutes ago runs; due 2 hours ago records `missed`.
- Overlap: an active run yields `already_running` and no second run.
- Preview endpoint and the scheduler agree on the next occurrence for a table
of expressions, including a DST-crossing one.
@@ -1,538 +0,0 @@
# 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.
@@ -1,441 +0,0 @@
# 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 @@
# Windows agent parity: OS updates and workloads
Date: 2026-08-13
## Goal
Bring the Windows agent up to the Linux agent on two subsystems: OS update
check/apply, and the workload registry (collection, control, logs). Everything
else about the Windows agent stays as it is.
Out of scope, deliberately:
- **Package inventory and CVE findings.** `trivy-db` carries no Windows feed, so
a Windows finding needs a different source, a different matcher and a
different version comparison. That is its own project, and until it exists a
Windows host correctly reports `status: unsupported` rather than "0 findings".
- **SSH key management on Windows.** `administrators_authorized_keys` is a real
possibility but a separate decision.
- **winget.** Third-party app upgrades are a different question from OS
patching, and winget is absent on Server Core and older builds.
## Current state
The Windows agent registers, heartbeats, reports inventory, runs workflow steps
through PowerShell, relays console connections and self-updates via MSI. Four
gates stop it doing more:
| Gate | Location |
| --- | --- |
| `runtime.GOOS != "linux"` early return | `agent/internal/workloads/workloads.go`, `agent/internal/sync/workloads.go` (twice) |
| package-manager detection finds nothing | `agent/internal/updates/updates.go` (`detectPM`) |
| hard error | `agent/internal/packages/packages.go` (out of scope here) |
| `authorized_keys` write skipped | `agent/internal/sync/sync.go` (out of scope here) |
## Approach
The platform split moves into the agent, expressed as build tags following the
existing `inventory/collect_linux.go` / `collect_windows.go` /
`collect_other.go` precedent. The control plane stays OS-blind: `ReportWorkloads`,
`ControlWorkload`, `WorkloadLogs` and `ApplyUpdates` need no changes at all,
because a Windows service is reported as the same `unit` kind a systemd service
is.
Build tags rather than `runtime.GOOS` switches so PowerShell command strings do
not ship in the Linux binary, and so a platform left unimplemented is a compile
error rather than a silent no-op at runtime.
## Updates
### Layout
```
agent/internal/updates/
updates.go # PackageUpdate; CheckAvailable/ApplyAll declared once
updates_linux.go # existing detectPM, checkApt/DnfYum/Pacman/Zypper/Apk, ApplyAll
updates_windows.go # Windows Update COM, driven through PowerShell
updates_other.go # //go:build !linux && !windows — no-ops
```
`updates_other.go` carries the build constraint for the same reason
`inventory/collect_other.go` does: `_other` is not a GOOS suffix, so without the
constraint the file compiles everywhere and collides.
### Checking
One PowerShell invocation, `-NoProfile -NonInteractive`, emitting JSON:
```powershell
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
```
The Windows Update COM API is used rather than the `PSWindowsUpdate` module
because it is present on every supported Windows, needs no PowerShell Gallery
install, and works unchanged against a WSUS server on an air-gapped fleet. The
agent runs as `LocalSystem`, which has the rights the API requires.
Each result maps to a `PackageUpdate`:
| Field | Value |
| --- | --- |
| `Name` | the update Title |
| `CurrentVersion` | empty |
| `NewVersion` | the KB article ID, e.g. `KB5034123` |
`CurrentVersion` is empty because a Windows update is not a version bump of a
named package, and inventing a current version would put a wrong string in front
of an operator. The KB ID goes in `NewVersion` because it is the identifier
people actually search for.
Timeout: 10 minutes. The first search after a boot contacts Microsoft Update and
is routinely slow.
### Applying
The same COM session: `CreateUpdateDownloader` then `CreateUpdateInstaller`,
over the updates returned by the search above, skipping any that require user
input. EULAs are accepted programmatically; an update whose EULA cannot be
accepted is skipped rather than failing the batch.
The operation fails when the installer's `ResultCode` is not 2 (succeeded) or 3
(succeeded with errors).
Timeout: 60 minutes. A patch-Tuesday cumulative genuinely takes that long, and
the Linux path's existing 5-minute cap is already tight.
**The agent never reboots the host.** A control plane silently restarting a
production server is unrecoverable from the UI, and the reboot is a decision a
person or a workflow makes. Instead the need for one is reported.
### Reboot required
A new field `reboot_required` on `InventoryReport`, added to
`proto/vantage/v1/vantage.proto` and to both hand-written `pb` copies
(`agent/internal/grpc/pb`, `server/internal/grpc/pb`) in the same commit.
It travels on the inventory report rather than the update report because it is a
host property like the kernel version, and it is set on the **static** snapshot
only — every 15 minutes rather than every 30 seconds. A host rebooted by hand
clears the flag in a quarter of an hour instead of showing it for up to a full
one, and the detection costs a PowerShell process on Windows, which is not
something to spawn twice a minute forever.
It is set in `agentsync.runInventory`, not inside the `inventory` package, so
`inventory` gains no dependency on `updates`.
Both platforms set it, since parity is free here:
- Linux: `/var/run/reboot-required` exists, or `dnf needs-restarting -r` exits
non-zero.
- Windows: the `Microsoft.Update.SystemInfo` COM object's `RebootRequired`
property, falling back to the pending-reboot registry keys
(`Component Based Servicing\RebootPending`,
`WindowsUpdate\Auto Update\RebootRequired`,
`Session Manager\PendingFileRenameOperations`).
`services.ReportInventory` stores it on `servers.inventory`.
## Workloads
### Layout
```
agent/internal/workloads/
workloads.go # Result, Collect, Hash — Collect calls collectUnits
docker.go # unchanged, shared: shells to the docker binary
systemd_linux.go # was systemd.go
services_windows.go # new: Win32_Service collection
control.go # shared validation; platform halves split out
control_linux.go # docker/systemctl, /proc/self/cgroup own-container check
control_windows.go # Start/Stop/Restart-Service, VantageAgent protection
logs.go # shared: capLog, MaxLogLines, MaxLogBytes
logs_linux.go # docker logs / journalctl
logs_windows.go # docker logs / Get-WinEvent
```
`Collect` loses its `runtime.GOOS != "linux"` return and calls
`collectUnits(ctx)`, which is the systemd collector on Linux and the service
collector on Windows. `runWorkloads` and `reportWorkloads` in
`agent/internal/sync/workloads.go` lose all three of their platform returns.
`docker.go` stays shared and ungated. It shells to the `docker` binary, which
behaves identically on Windows, so a Docker Desktop or Mirantis host reports its
containers with no new code. `DockerOK` / `DockerError` keep their existing
three-state meaning: not installed (the common case, not a fault), installed but
not responding, and running nothing.
### Collecting Windows services
`Get-CimInstance Win32_Service` converted to JSON — not `Get-Service`, which
exposes neither `PathName` nor `StartMode`, and the filter needs both.
A service is reported when its executable does **not** resolve under
`%SystemRoot%\System32`, and it is running, failed, or has `StartMode=Auto`
while stopped. This mirrors the systemd collector's intent: show what an
operator installed, and show what is meant to be up but is not.
Path parsing strips surrounding quotes and trailing arguments before the
`%SystemRoot%` comparison. `"C:\Program Files\X\x.exe" -service` is one path
with one argument, and splitting naively on whitespace misfiles a substantial
share of a real fleet.
Field mapping:
| Workload field | Source |
| --- | --- |
| `Kind` | `"unit"` |
| `ID` | `Name` (the service name) |
| `Name` | `DisplayName` |
| `State` | `running` / `stopped` / `failed`, from `State` plus `ExitCode` |
| `Health`, `Image`, `Stack`, `Ports`, `Restarts` | unset |
`Kind: "unit"` and the existing `systemd_ok` / `systemd_error` fields are reused
rather than a `service` kind and `services_ok` fields being added. That would
cost a proto change, both pb copies, the server model, the service layer and the
web client, and would teach every existing consumer a second kind — to describe
the same thing. The naming is corrected where it is read, in the UI, which knows
the server's OS.
`State` values match the ones the UI already colours, so no web change is needed
for the rows themselves.
### Protection
The protected set stays computed and enforced agent-side, as it is on Linux: the
control plane may name a target, but the agent decides what it will do to
itself.
On Windows the protected workload is the `VantageAgent` service — the NSSM
service name written by `installer/setup.ps1` — matched case-insensitively,
because Windows service names are. `detectOwnContainer` and its
`/proc/self/cgroup` read move to `control_linux.go`; the Windows build returns
no own-container ID.
`ErrProtected` still surfaces as HTTP 409 from the API, and the reported
`Protected` flag remains a courtesy that greys the button rather than the
boundary.
### Control
`Start-Service`, `Stop-Service -Force`, `Restart-Service -Force`, under the same
90-second `controlTimeout`, with the error text taken from PowerShell's stderr.
`sc.exe` is avoided because it returns before the operation completes, which
turns a timeout into a false success. `-Force` is required because
`Stop-Service` without it refuses when other services depend on the target, and
that refusal reads to an operator as a silent no-op.
### Logs
`Get-WinEvent` with a filter hashtable over the `System` and `Application` logs,
provider names matching the service name, its display name, and
`Service Control Manager`, newest first, capped by the requested tail.
Each event is formatted as `<ISO 8601 timestamp> <Level> <Message>`, which is
the same shape `journalctl --output=short-iso` produces, so the log dialog needs
no per-platform rendering.
Service Control Manager logs every service on the host under one provider, so
its events are filtered client-side to those naming the target service.
`capLog` is shared and unchanged: 500 lines and 256KB, whichever binds first,
trimmed from the front. There is still no follow mode.
An empty result returns an empty string and no error. A service that has logged
nothing is normal, and an error there would read as a broken feature.
## Server and web
The server changes in one place: `services.ReportInventory` persists
`reboot_required`.
The web changes in three, all keyed on the same `os_info` test
`MaintenanceTab.tsx` already uses (`server.os_info?.toLowerCase().includes("windows")`)
rather than on `os_type`. `os_type` is stored and serialised but unread by
`web/` today, and introducing a second Windows test in the same component is how
the two come to disagree. `WorkloadList` takes the result as a prop, since it
receives only a `serverId`:
1. `web/components/workloads/WorkloadList.tsx` — takes an `isWindows` prop from
the server detail page, and the systemd status lines become
platform-worded. On Windows the error line reads "Windows services could not
be read" and the "systemd is not in use on this server" line is not rendered
at all. The empty-state line drops "on Linux only". The Docker lines are
unchanged.
2. Server detail — a `Reboot required` pill beside the update count when the
flag is set, placed with the update panel because that is what caused it.
3. The Updates panel's Windows copy describes a list of KB articles rather than
package upgrades, since `current_version` is empty on that platform.
## Testing
The Windows collectors are, in substance, parsers of PowerShell output. Parsing
is separated from invocation and table-tested against captured real output. The
`agent` module has no tests at all today, so these are the first — they live
beside the parsers as ordinary `_test.go` files, run with `go test ./...` from
`agent/`, and need no new dependency:
- `Win32_Service` JSON, including a quoted path with arguments, a
`%SystemRoot%\System32` service that must be filtered out, a stopped
`StartMode=Auto` service that must be kept, and a failed service with a
non-zero `ExitCode`.
- Update searcher JSON, including an update with no KB ID.
- `Get-WinEvent` JSON, including a Service Control Manager event for another
service that must be filtered out.
- Pending-reboot detection from registry key presence.
`capLog` and `Hash` are unchanged and gain no tests.
The invocation halves are verified by hand on a Windows host: check, apply,
service start/stop/restart, a protected refusal on `VantageAgent`, and logs on
both a chatty service and a silent one.
`GOOS=windows go build ./...` and `GOOS=linux go build ./...` both belong in the
implementation plan as explicit steps — a build-tag split is exactly the change
that compiles on the machine you are sitting at and nowhere else. CI already
cross-builds the agent on release, so no workflow change is needed.
@@ -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
+56 -41
View File
@@ -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).
+53 -45
View File
@@ -4,64 +4,70 @@ 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, heartbeat, report inventory, run workflow steps,
serve the browser console, check and apply OS updates, and report workloads
(services and containers). Managing `authorized_keys` is a Linux-only
feature, and so is package inventory and CVE scanning — the vulnerability
feeds this project uses carry no Windows data.
:::
## 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 +76,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.
+29 -36
View File
@@ -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
+11 -6
View File
@@ -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
View File
@@ -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.
+16 -16
View File
@@ -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.
+9 -15
View File
@@ -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.

Some files were not shown because too many files have changed in this diff Show More