Compare commits

...
Author SHA1 Message Date
mrhid6 7a0a1953f6 fix: Fixed api url on web
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 7m37s
2026-08-25 13:53:59 +00:00
mrhid6 3e4ccc9720 feat: Added more debug logging
Chart Release / chart (push) Successful in 16s
Server Deploy / deploy (push) Successful in 7m1s
2026-08-25 13:26:23 +00:00
mrhid6 e5947489e4 fix: Fixed status page published switch 2026-08-25 13:26:10 +00:00
mrhid6 0a7a10aeed feat: Added status pages to license page 2026-08-25 13:10:28 +00:00
mrhid6 28b813ba64 feat: sell status pages as a per-instance licence feature
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 9m30s
2026-08-25 09:25:05 +00:00
mrhid6 68160dc681 docs: correct the status page URL for self-hosted, trim to what ships
- The status page URL was given only as `<instance>.vantage.<tld>`, which a
  self-hosted install does not serve. Both deployments are now described.
- The banner is documented as one notice: the editor exposes no level picker
  and the view renders every level identically.
- `pending` added to the component states, which a monitor with no result yet
  renders.
- Delete page documented alongside un-publish.
- `TRUSTED_PROXIES` names the LAN case: with the RFC1918 default, a client on a
  private range reaching the server directly is itself trusted and can spoof
  `X-Forwarded-For` — and now `X-Forwarded-Host`. Narrow it to the proxy.
- CLAUDE.md: scopes are nine resources, not eight; `status-pages` added to the
  REST route table; the host-resolution rules recorded under Status pages.
2026-08-25 09:05:06 +00:00
mrhid6 32e7420d89 fix: wire status page and incident delete, stop promising a name we do not publish
- The display-name placeholder showed the monitor's own name, reading as "leave
  this blank and we will use it". The server deliberately does the opposite: a
  blank `display_name` publishes the raw monitor id, because publishing an
  internal name has to be a decision. The placeholder now says "Public name
  (required)" and Save is refused until every component has one, so nobody adds
  five monitors and discovers five UUIDs on their public page. The server
  fallback is unchanged.
- `deleteStatusPage` and `deleteStatusIncident` existed in the api client and
  were wired to nothing, and the page address is immutable — delete was the
  only correction for a typo and there was no way to reach it. The editor
  header gains a typed-confirmation Delete page, and each incident row a
  confirmed delete, both on the existing ConfirmDialog.
- The create modal's address hint had lost its em dash and read as a broken
  sentence.
2026-08-25 09:05:06 +00:00
mrhid6 7e1d67dba4 fix: file live outages as active, refuse "operational" over zero components
- A derived monitor outage with no `resolved_at` went to `History`, so an
  ongoing disruption was listed under "Past incidents" while the component pill
  beside it read Down. Unresolved now goes to `ActiveIncidents`.
- `overallState` returned `up` when nothing was counted: "all systems
  operational" claimed from no evidence at all. A page with no components now
  reports `no_data`, which the view already renders as "Status unknown".
- `EnsureStatusPageIndexes` returned on the first failure, so a transient
  failure on the `status_pages` index left `status_incidents` with no unique
  `(instance_id, incident_id)` index — a correctness property, not a scan
  optimisation. All three are attempted and the failures joined.
2026-08-25 09:04:54 +00:00
mrhid6 da1dc90ac5 fix: resolve the public status page's tenant from a trusted X-Forwarded-Host
The SSR fetch set `Host` to the visitor's hostname. `Host` is a forbidden
header name and undici discards it silently, so the Go server saw
`server:8080`, `hostSlug` returned "", `InstanceFromHost` returned false and
every public status page 404'd on every deployment. The feature did not work.

- `web/` now forwards the visitor's host as `X-Forwarded-Host`, and their
  address on `X-Forwarded-For` — without the latter gin sees a request from the
  Next pod with no XFF and every visitor of every page shares one 120/min
  bucket, tripped by exactly the traffic an outage produces.
- `publicStatusInstance` honours `X-Forwarded-Host` only when `c.RemoteIP()` is
  in `TRUSTED_PROXIES`. It is a tenant selector, so an untrusted peer must not
  be able to name one; `RemoteIP()` rather than `ClientIP()` because the latter
  is reconstructed from the very headers being judged. `TrustedProxies()` moves
  from main.go into the api package so the variable keeps one parser.
- A host naming no slug on a non-cloud deployment resolves the sole instance,
  the way bootstrap does. A self-hosted install at vantage.acme.com or an IP
  has no slug and could never serve a status page; more than one instance is a
  404 rather than a guess, and an unknown-but-well-formed slug stays a 404.
- `InstanceFromHost` gains an explicit-host variant rather than a second copy
  of the slug rules, and now caches negative lookups: an unknown host cost a
  Mongo query per anonymous request, which is also a timing oracle separating
  "no such instance" from "instance exists, page does not".
- The handler's `@Router` annotation is dropped. openapi.json declares one
  server of `/api`, so it published `/api/public/status/{pageId}` — a path that
  does not exist. The real address is described in prose instead.
2026-08-25 09:04:47 +00:00
mrhid6 72c9492223 docs: status pages 2026-08-25 08:44:38 +00:00
mrhid6 fa67d839cd fix: status page editor error handling, maintenance validation and UTC display 2026-08-25 08:40:05 +00:00
mrhid6 1452928b75 feat: status page authoring UI 2026-08-25 08:32:43 +00:00
mrhid6 bf10023f35 fix: empty slices rather than null on the unavailable status snapshot 2026-08-24 19:42:16 +00:00
mrhid6 f4f41e400b feat: public status page 2026-08-24 14:53:42 +00:00
mrhid6 3abbdc41d6 feat: status page authoring API
Adds owner|admin routes under /api/status-pages for authoring status pages
and their incidents/maintenance windows, gated by the status_pages licence
feature. Adds the "status" token scope resource and the ten route-scope
entries, and regenerates the committed OpenAPI document.

Also types ErrPageInvalid as a sentinel for status page/incident validation
failures (previously bare errors), so statusPageError maps them to 400
instead of 500, and createStatusIncident/updateStatusIncident route through
the shared error mapper rather than hand-rolling a 400 for any service error.
2026-08-24 14:43:17 +00:00
mrhid6 6ba54f690c fix: default TRUSTED_PROXIES in shipped deployments and route /public/ through ingress 2026-08-24 14:35:29 +00:00
mrhid6 a3c6b2a305 feat: public status page endpoint with per-address rate limit 2026-08-24 14:29:24 +00:00
mrhid6 21a2d077d8 feat: cached public status snapshot with licence gate 2026-08-24 14:24:07 +00:00
mrhid6 6263c7e16f fix: clear resolved_at when reopening a status incident via appended update 2026-08-24 14:21:26 +00:00
mrhid6 161835802d feat: authored status incidents and maintenance windows 2026-08-24 14:18:06 +00:00
mrhid6 6f998ff506 feat: status page CRUD and cache invalidation 2026-08-24 14:15:25 +00:00
mrhid6 d192589790 fix: maintenance repaint no longer zeroes no_data uptime; strengthen redaction test 2026-08-24 14:12:38 +00:00
mrhid6 21c2bb2646 feat: public status snapshot assembly and redaction boundary 2026-08-24 14:07:10 +00:00
mrhid6 9c0bbd13dd feat: status page id validation and cache key 2026-08-24 14:02:46 +00:00
mrhid6 1c15961309 feat: status page schema, licence feature and indexes 2026-08-24 14:00:00 +00:00
mrhid6 383b763a66 docs: attach approved status page mockup to the implementation plan 2026-08-24 13:52:07 +00:00
mrhid6 3e99a9df33 docs: public status pages implementation plan 2026-08-24 13:42:44 +00:00
mrhid6 f1b6f90345 docs: public status pages design spec 2026-08-24 13:32:24 +00:00
mrhid6 2a660697c5 docs: Updated troubleshotting doc
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m4s
2026-08-24 12:50:44 +00:00
mrhid6 0c08dda635 feat: Useragent
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 6m5s
2026-08-24 12:17:46 +00:00
mrhid6 22b99ff895 feat: Monitor grath zoom
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 6m31s
2026-08-24 10:58:50 +00:00
mrhid6 2fab784ba7 feat: Monitor groups and chart information
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 6m35s
2026-08-24 10:30:23 +00:00
mrhid6 83cdf92575 feat: Updated edit monitor page
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 43s
2026-08-24 09:25:43 +00:00
mrhid6 aa1c8e4aa1 feat: Hide secrets on api and channels
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 8m5s
2026-08-14 12:23:36 +00:00
mrhid6 ac61015cc0 fix: Fixed incorrect openapi doc
Chart Release / chart (push) Canceled after 0s
Server Deploy / deploy (push) Successful in 9m51s
2026-08-13 13:04:44 +00:00
mrhid6 a0fbf5b9ba fix: Word and colour Windows workloads correctly across the UI
Server Deploy / deploy (push) Canceled after 0s
Chart Release / chart (push) Canceled after 0s
Agent Release / build (push) Successful in 14m1s
Agent Release / msi (push) Successful in 1m22s
2026-08-13 12:57:37 +00:00
mrhid6 ddf0814803 docs: Fix Windows package-inventory and poll-loop claims in CLAUDE.md 2026-08-13 12:28:51 +00:00
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
mrhid6 6dced22499 fix: Fixed agent collect packages
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 21s
Agent Release / build (push) Successful in 58s
Agent Release / msi (push) Successful in 1m34s
2026-08-06 16:20:34 +01:00
mrhid6 5cee53dc5f feat: Better button description
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 2m23s
2026-08-06 15:51:18 +01:00
mrhid6 81248bb159 style: bring the vulnerabilities page onto the house page shape
Every other page under app/(app) opens with `p-4 sm:p-6 lg:p-8` and the
layout adds none of its own, so this page alone sat flush against the shell
edge. Its h1 was text-xl where every other page is text-2xl.

The findings list was a stack of separately bordered cards; it is now rows
inside one Card, separated by border-border-soft, matching the monitors and
workflows lists. Loading is the shared spinner rather than a line of text,
the error is the shared danger strip, and the empty state uses the same
proportions as the monitors one.
2026-08-06 15:49:39 +01:00
mrhid6 6354d54de8 feat: added addon price to pricing page 2026-08-06 15:48:20 +01:00
mrhid6 da6d64f95c fix: give the scratch server image a /tmp for the vulnerability database
The runtime stage is FROM scratch, which has no /tmp, so vulnsched died at
startup with "temp dir: stat /tmp: no such file or directory" and no scan
ever ran. Nothing in the server wrote to a temporary directory before the
trivy-db puller, which is why this only appeared now.

scratch cannot mkdir its own, so the directory is staged in the builder at
1777 and copied in. Also corrects CLAUDE.md, which described this image as
Alpine; the time/tzdata import it justifies is if anything more load-bearing
on scratch.
2026-08-06 15:46:30 +01:00
mrhid6 9ba3d4a61f feat: Vulnerability Scanning feature on license page
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m20s
2026-08-06 15:40:13 +01:00
mrhid6 eee236a072 fix: tidy server go.mod for the vulnerability database dependencies
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Canceled after 2m11s
trivy-db, oras-go, the three version comparators and robfig/cron were
recorded as indirect with an incomplete go.sum, which builds locally
against a warm module cache but fails in CI with "updates to go.mod
needed". trivy-db pulls testify into the build graph, and its hashes
were missing entirely.
2026-08-06 15:18:57 +01:00
mrhid6 9df89e2db4 fix: surface vuln_scanning across licence, staff and pricing UI
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Canceled after 59s
The catalogue row alone was not enough; the feature was invisible in
three places and mislabelled in a fourth.

PlanConfigurator rendered every key that was not "console" as "Single
sign-on", so the staff checkbox granting vulnerability scanning was
labelled single sign-on. Feature wording was duplicated between the staff
configurator and the purchase form and the copies had drifted, so it now
lives in adminsite/lib/features.ts and both read from it.

The customer licence panel showed raw keys; it now labels them.

Pricing gains a comparison row. The add-on block with a monthly price is
deliberately NOT added: that is a pricing decision, and the Paddle price
IDs for the new catalogue rows have to be pasted in before it can be sold
anyway.
2026-08-06 15:10:26 +01:00
mrhid6 f60c509b47 feat: vuln_scanning entitlement and documentation
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Failing after 1m11s
Agent Release / build (push) Successful in 1m0s
Agent Release / msi (push) Successful in 2m18s
Adds license.FeatureVulnScanning as the one name for the feature and a
catalogue row per deployment/tier, following console and oidc: features
are opt-in per customer, so no plan bundles it.

Documents the subsystem in CLAUDE.md, including that ScopedCollections is
the canonical registry instance deletion derives from — there is no
separate deletion list, which the plan had wrong.
2026-08-06 14:44:11 +01:00
mrhid6 84dfcfeac7 feat: vulnerability findings UI
Fleet board grouped by CVE, a per-server section on server detail, and
alert rules beside the channels they consume.

The server detail page has no tab pattern despite the plan saying to
follow one, so this adds a section in the existing vertical stack.

Three states are kept visually distinct because they are identical if
handled carelessly and only one is good news: never reported, no advisory
feed for the distribution, and scanned-and-clean. Database freshness sits
with the findings rather than in settings for the same reason.
2026-08-06 14:40:37 +01:00
mrhid6 5dda3b5c4a feat: vulnerability scanning pipeline, matcher, scheduler and API
Completes tasks 10-15 and fixes what was outstanding:

- vulndb.Pull implemented with oras-go, streaming the ~50MB layer and
  staging both files before replacing either, so a failed pull leaves the
  previous database intact rather than a half-written one.
- db.go: Vulnerability.Severity is a string, not trivy Severity, so the
  int conversion did not compile. Severity now resolves vendor (highest
  when vendors disagree) then NVD then unknown, and CVSS is read too.
- findings.go: added sweepFixedFindings plus the fleet query, severity
  counts, rescan flag and accept/unaccept the API needs.
- vulnrules.go: added rule CRUD and the digest builder. ResolveTargets
  returns []models.Server, not []string, so filterByServers was wrong.
- api/vulnerabilities.go was an empty file while handlers.go registered
  twelve routes against it; written, grouped by CVE.
- shared/mail: added the missing sender. The templates were orphaned and
  the HTML one was a copy of the text one, defining "subject" (which
  html/template would escape) and emitting no markup. render.go parses
  every template in init(), so a bad one panics server, admin and sitesvc
  at boot — go build never runs init(), which is why nothing complained.
- notify: digests dispatch through their own path so SMTP gets the digest
  template rather than arriving dressed as a monitor alert.
2026-08-06 14:33:46 +01:00
mrhid6 db64320bd8 feat: agent reports installed packages on the hourly loop
SyncKeys now returns the whole response so the poll can carry
CollectPackages; a separate RPC for one boolean would be a message every
30 seconds for a value that changes when a licence does.

The flag is an atomic: the 30s poll writes it, the hourly package loop
reads it, and they are different goroutines.
2026-08-06 13:21:13 +01:00
mrhid6 583f60771c feat: store agent package reports and serve the collect flag
VulnScanningEnabled reads GetLicenseState(...).Feature("vuln_scanning")
and requires an active licence, never switching on tier. ReportPackages
re-checks it server-side: the agent flag is the optimisation, this is
the boundary.
2026-08-06 13:19:39 +01:00
mrhid6 a92c3190c2 feat: ReportPackages wire types with hash short-circuit
The pb packages are hand-written, not protoc-generated, and the wire
codec is JSON (encoding.RegisterCodec(JSONCodec{})). Field numbers in
the .proto are documentation; JSON field names are the contract. Both pb
packages edited by hand to match.

SyncResponse.collect_packages is omitempty and absent decodes as false,
so an older server leaves agents collecting nothing rather than
collecting without a licence.
2026-08-06 13:17:44 +01:00
mrhid6 3a6d24fe0e feat: models and indexes for package inventory and CVE findings
Adds server_packages, vuln_findings and vuln_alert_rules to
ScopedCollections rather than to a separate deletion list. purgeInstance
derives its collection list from that registry, so instance deletion
follows automatically and there is no second copy to drift.
2026-08-06 11:59:00 +01:00
mrhid6 c277ecff44 feat: agent collects installed packages per package manager 2026-08-06 11:56:41 +01:00
mrhid6 bd690c94c3 feat: agent parses /etc/os-release for distro identification 2026-08-06 11:55:45 +01:00
mrhid6 a22fdf197e feat: map OS family and version to trivy-db advisory buckets 2026-08-06 11:55:01 +01:00
mrhid6 bd24b03cac feat: version comparators for distro package ordering 2026-08-06 11:54:13 +01:00
mrhid6 3afc4ab012 docs: workload registry plan; remove tests from both plans
Both plans now verify by build, vet and manual checks written into the
tasks. Spec verification sections updated to match so they no longer
describe tests that will not be written.
2026-08-06 11:27:37 +01:00
mrhid6 d1ac3e98ce docs: design for the workload registry
Agents enumerate Docker containers, compose stacks and systemd services;
start/stop/restart and bounded log snapshots from the UI.

Sub-project B, Linux only. Live log following stays in the console.
2026-08-06 11:11:26 +01:00
mrhid6 5bba54f3e5 fix: Fixed style layout on workflow run page 2026-08-06 10:50:31 +01:00
mrhid6 fe7bc300e2 docs: implementation plan for package inventory and CVE findings
17 tasks, TDD where the logic is pure. Corrects two spec claims:
the server reads features via License.HasFeature rather than admin's
entitlement directly, and shared/mail/render_test.go does not exist.
2026-08-06 10:49:10 +01:00
mrhid6 00c03c365d docs: design for package inventory and CVE findings
Agents report installed packages; the control plane matches them against
trivy-db and raises findings that link to the existing ApplyUpdatesCmd
patching path.

Scoped to sub-project A, Linux only. Container registry, image scanning
and compliance baselines are separate specs.
2026-08-06 10:33:54 +01:00
mrhid6 dc8dd3dd58 fix: Fixed step descriptions
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 35s
2026-08-04 17:45:31 +01:00
mrhid6 85a8865892 feat: restyle the steps table and add 22 default steps
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 2m46s
2026-08-04 17:34:18 +01:00
mrhid6 50a9ac5fdc fix: count tag-matched servers in the workflows list 2026-08-04 17:28:01 +01:00
mrhid6 3388d2f895 fix: Fixed padding on add step button
Chart Release / chart (push) Successful in 22s
Server Deploy / deploy (push) Successful in 41s
2026-08-04 17:24:40 +01:00
mrhid6 3a77fc2abd feat: edit target servers and tags together in the workflow modal 2026-08-04 17:21:12 +01:00
mrhid6 3d59836d0c feat: dual list box for workflow target servers
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 5m19s
2026-08-04 17:13:50 +01:00
mrhid6 d9184312aa fix: schedule card placement, preview state, and scheduled-workflow docs 2026-08-04 17:08:11 +01:00
mrhid6 b9802e6b04 docs: Updated docs 2026-08-04 17:03:29 +01:00
mrhid6 c2635ed51a fix: Fixed schedule workflow col
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 41s
2026-08-04 14:42:10 +01:00
mrhid6 b21ac05547 feat: show workflow schedules in the list
Chart Release / chart (push) Successful in 24s
Server Deploy / deploy (push) Successful in 2m34s
2026-08-04 14:16:13 +01:00
mrhid6 484b620867 feat: schedule editor on the workflow page 2026-08-04 14:13:31 +01:00
mrhid6 439bc2ed7d feat: schedule methods on the web api client 2026-08-04 14:10:04 +01:00
mrhid6 a1e6986a64 feat: fire scheduled workflow runs from the housekeeping leader 2026-08-04 13:53:42 +01:00
mrhid6 d0e1cc4ad6 feat: cron arithmetic and persisted workflow schedules 2026-08-04 13:51:10 +01:00
mrhid6 b877024365 docs: server tags and workflow tag targeting 2026-08-04 13:44:16 +01:00
mrhid6 2de7ac116b feat: filter the fleet by tag and target workflows by tag selector 2026-08-04 13:42:47 +01:00
mrhid6 fa1fd14ed1 feat: view and edit server tags 2026-08-04 13:38:42 +01:00
mrhid6 d1b3cd2f74 feat: target workflow runs by tag selector 2026-08-04 13:36:43 +01:00
mrhid6 e00a0da5d9 feat: tag endpoints for servers 2026-08-04 13:34:22 +01:00
mrhid6 fef0b7c7a1 feat: read and write server tags, resolve targets from the database 2026-08-04 13:33:00 +01:00
mrhid6 efd29dc259 feat: parse tag filters and resolve targets as ids union tag selector 2026-08-04 13:31:04 +01:00
mrhid6 13cd41d202 feat: validate server tags and add the model field 2026-08-04 13:30:06 +01:00
mrhid6 3530ce6cb7 docs: implementation plans for server tags and scheduled workflows 2026-08-04 13:26:04 +01:00
mrhid6 09522c2566 docs: design for server tags and scheduled workflows 2026-08-04 13:13:12 +01:00
mrhid6 80f0afb28b feat: Updated monitors pages
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m25s
2026-08-04 12:19:15 +01:00
mrhid6 287bd9657b fix: Fixed paddle relink sub
Chart Release / chart (push) Successful in 27s
Server Deploy / deploy (push) Successful in 1m17s
2026-08-03 17:34:39 +01:00
mrhid6 b5f684c4fe fix: Fixed paddle subs
Chart Release / chart (push) Successful in 28s
Server Deploy / deploy (push) Successful in 1m17s
2026-08-03 15:32:01 +01:00
mrhid6 1f08e90009 feat: Removed email alert settings
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 5m15s
2026-08-03 14:40:15 +01:00
mrhid6 6881d92d0a fix: local-login toggle no longer reverts unsaved settings edits
Chart Release / chart (push) Successful in 26s
Server Deploy / deploy (push) Successful in 4m8s
server/internal/services/settings.go SaveSettings takes alerts and
email as required (non-pointer) values and writes them unconditionally
- absent fields would blank stored settings, not just leave them
alone. onLocalLoginChange was building its payload from the stale
loaded settings object instead of the in-progress form state
(thresholdMinutes/logRetentionDays) that handleSubmit uses, so editing
the offline threshold and then flipping the toggle silently reverted
the edit. Both paths now submit the same in-progress values.
2026-08-03 14:13:05 +01:00
mrhid6 5e016c6584 fix: audit ack_notice and stop misreporting DB errors as lockouts
ackAuthProviderNotice mutated callback_notice with no audit event; it
now writes auth_provider.ack_notice like create/update/delete.

guardProviderChange's callers turned any error from
CountEnabledAuthProviders into a 409 last_provider, so a transient
Mongo error was reported to the operator as an unremovable lockout.
Only services.ErrLockout now produces the 409; anything else is a 500.
2026-08-03 14:12:21 +01:00
mrhid6 537b8758ff fix: purge auth_providers when reaping an instance
auth_providers was missing from ScopedCollections, so reap.go's
scopedCollectionsForPurge() (derived from that list) never deleted an
instance's providers, leaving orphaned rows holding encrypted client
secrets forever. Verified migration 0004's $rename over org_id->instance_id
is a no-op here since auth_providers never carried org_id.
2026-08-03 14:11:42 +01:00
mrhid6 c03360333b fix: single source of truth for local-login lockout rescue
HandleLocalLogin and HandleListPublicProviders each computed their own
answer to whether password sign-in must stay available, and they could
disagree: an instance with local login off and a licence that lapses
loses its only provider and its password form in the same moment, with
no endpoint left to recover. services.LocalLoginPermitted is now the
one predicate both call.
2026-08-03 14:11:22 +01:00
mrhid6 fa7c5d341d docs: fix stale auth-provider references in rest-api and licensing docs 2026-08-03 11:08:22 +01:00
mrhid6 b6fc8c3f77 docs: document multiple auth providers and the callback URL change 2026-08-03 11:05:40 +01:00
mrhid6 37f2c1457e feat: manage multiple sign-in providers from settings 2026-08-03 11:00:25 +01:00
mrhid6 3a626922a5 feat: render one login button per configured auth provider 2026-08-03 10:56:03 +01:00
mrhid6 dde47de145 feat: auth provider REST API and public provider discovery 2026-08-03 10:51:35 +01:00
mrhid6 f3b9f6f286 feat: add GitHub OAuth2 provider branch 2026-08-03 10:48:19 +01:00
mrhid6 f1c3f67864 feat: per-provider SSO start and callback routes 2026-08-03 10:45:23 +01:00
mrhid6 8f5873afca refactor: carry provider id in the OIDC state token 2026-08-03 10:41:30 +01:00
mrhid6 e22faebfcd feat: migrate instance_oidc into auth_providers (0005) 2026-08-03 10:38:52 +01:00
mrhid6 e2b01b62a5 feat: add local_login_enabled setting with absent-means-on default 2026-08-03 10:36:48 +01:00
mrhid6 0858693d57 feat: add auth provider service layer and lockout guard 2026-08-03 10:33:36 +01:00
mrhid6 45f7c0c393 feat: add AuthProvider model and identity provider presets 2026-08-03 10:30:21 +01:00
mrhid6 c56bfb7270 docs: implementation plan for multiple auth providers 2026-08-03 10:23:35 +01:00
mrhid6 eb45072031 feat: Removed unused test units 2026-08-03 10:18:01 +01:00
mrhid6 1e2132c1a1 docs: Cleanup old specs and plans 2026-08-03 10:15:54 +01:00
mrhid6 19ef773690 docs: drop legacy OIDC callback from multi-provider design 2026-08-03 10:13:26 +01:00
mrhid6 c5aae0614a docs: design for multiple auth providers 2026-08-03 10:09:13 +01:00
mrhid6 17d97aaf52 feat: More logging for command stream
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m24s
Agent Release / build (push) Successful in 10m37s
Agent Release / msi (push) Successful in 36s
2026-07-31 17:20:35 +01:00
mrhid6 1fb9bd827f feat: Added ping command
Chart Release / chart (push) Successful in 18s
Agent Release / build (push) Successful in 39s
Server Deploy / deploy (push) Successful in 55s
Agent Release / msi (push) Successful in 40s
2026-07-31 17:10:59 +01:00
mrhid6 8699dc5b7e fix: Renew presence on sub/pub
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 56s
2026-07-31 16:58:50 +01:00
mrhid6 71240f183c fix: Fixes to server shutdown stream
Chart Release / chart (push) Successful in 21s
Server Deploy / deploy (push) Successful in 1m2s
Agent Release / build (push) Successful in 43s
Agent Release / msi (push) Successful in 49s
2026-07-31 16:44:47 +01:00
mrhid6 01e8b0ba44 feat: Better debugging for console
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m25s
2026-07-31 16:31:19 +01:00
mrhid6 2aa4784518 feat: Better debugging for console
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 59s
2026-07-31 16:13:51 +01:00
mrhid6 f611cae438 feat: Better debugging for console
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 1m22s
2026-07-31 16:00:58 +01:00
mrhid6 1eb98ef962 feat: Better debugging for console
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m22s
2026-07-31 15:51:26 +01:00
mrhid6 6f86496f10 fix: Ffixes to console
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 1m9s
2026-07-31 15:05:21 +01:00
mrhid6 57a9b18102 fix: Guacd connection ip
Server Deploy / deploy (push) Successful in 9s
Chart Release / chart (push) Successful in 11s
2026-07-31 14:52:20 +01:00
mrhid6 36995fa62b fix: Fixed install and update scripts
Chart Release / chart (push) Successful in 9s
Server Deploy / deploy (push) Successful in 1m20s
2026-07-31 12:10:32 +01:00
mrhid6 9121fc461f fix: Fixed chart api routes for update
Server Deploy / deploy (push) Successful in 15s
Chart Release / chart (push) Successful in 10s
2026-07-31 12:03:45 +01:00
mrhid6 fc56bae5f9 chore: Bump chart version
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 8s
Agent Release / build (push) Successful in 38s
Agent Release / msi (push) Successful in 57s
2026-07-31 11:53:08 +01:00
mrhid6 ac75b3ef76 feat: chart deployment Type added
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 35s
2026-07-31 11:52:31 +01:00
mrhid6 e6fe463216 feat: Updated for api ingress routes
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 4m26s
2026-07-31 11:21:15 +01:00
383 changed files with 48887 additions and 41010 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"
}
]
}
]
}
}
+42
View File
@@ -59,6 +59,23 @@ jobs:
--set server.replicaCount=3 \
--set web.replicaCount=3 > /dev/null
# The reaper deletes whole instances, so "does this env appear only
# in cloud mode" is worth asserting rather than eyeballing.
- name: Check the reaper is cloud-only
run: |
set -eu
if helm template test "$CHART_DIR" | grep -q FREE_INSTANCE_REAP_AFTER; then
echo "FREE_INSTANCE_REAP_AFTER is set on a self-hosted render"
exit 1
fi
if ! helm template test "$CHART_DIR" \
--set server.env.deploymentType=cloud \
| grep -q FREE_INSTANCE_REAP_AFTER; then
echo "FREE_INSTANCE_REAP_AFTER is missing from a cloud render"
exit 1
fi
echo "ok: reaper configured in cloud mode only"
- name: Render against external Redis and MongoDB
run: |
helm template test "$CHART_DIR" \
@@ -76,6 +93,20 @@ jobs:
--set ingress.tls.certResolver=letsencrypt \
--set server.env.grpcHost=agents.example.com:443 > /dev/null
# The shape the cloud deployment actually uses: a wildcard tenant
# namespace, /api and /auth routed at the edge, and no apex — that
# belongs to the marketing site, which this chart does not deploy.
- name: Render a wildcard host with edge-routed API paths
run: |
helm template test "$CHART_DIR" \
--set ingress.enabled=true \
--set 'ingress.web.host=*.vantage.example.com' \
--set ingress.api.enabled=true \
--set ingress.grpc.host=agents.example.com \
--set server.env.grpcHost=agents.example.com:443 \
--set ingress.tls.secretName=vantage-tls \
--set ingress.tls.grpcSecretName=agents-tls > /dev/null
# The guards are load-bearing, so their absence is a regression the
# same way a broken render is. Each of these must fail.
- name: Check the guards still refuse bad values
@@ -99,10 +130,21 @@ jobs:
--set server.replicaCount=2 --set server.persistence.enabled=true
refuses "ingress with no web host" \
--set ingress.enabled=true
refuses "edge-routed API with an empty path list" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.enabled=false \
--set ingress.api.enabled=true \
--set 'ingress.api.paths=null'
refuses "gRPC ingress with no host" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set server.env.grpcHost=agents.example.com:443
refuses "an ingress that leaves /api unrouted" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
--set ingress.grpc.enabled=false \
--set ingress.api.enabled=false
refuses "gRPC ingress while grpcHost is still in-cluster" \
--set ingress.enabled=true \
--set ingress.web.host=vantage.example.com \
+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
+490 -18
View File
@@ -124,6 +124,86 @@ A library of reusable **steps** (bash or PowerShell scripts with declared inputs
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
### Scheduled workflows
A workflow may carry `schedule{enabled, cron, tz}` — standard **5-field** cron
and an IANA zone name, both validated at save time. `next_run_at` is
**persisted on the document, not held in memory**: a leader handover between
computing an occurrence and firing it would otherwise lose it or fire it twice,
the same argument that put `workflow_log_seq` in MongoDB.
`server/internal/workflowsched` ticks every 30s inside the **existing**
`bus.RunAsLeader("housekeeping", …)` alongside `monitorsched` and the sweepers —
one role, one lock. **The atomic claim, not the lock, is what prevents a double
fire**: the `UpdateOne` matches on the document *and* its current `next_run_at`
while setting the recomputed one, so a second process reaching the same workflow
matches nothing and does nothing. The lock only makes it cheap.
`workflowsched` **must not import `services`**`services` already imports it
for `SetSchedule`'s call to `NextOccurrence`, and Go has no cycles.
`TriggerWorkflow` and `LogEvent` are therefore injected as `workflowsched.Deps`
from `main.go`. Firing goes through the same `TriggerWorkflow` a person uses,
with `"schedule"` as the actor, so there is no second dispatch path and the run
detail page needed no changes.
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
runs on `scratch`, which ships no zone database, so without it
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
back to UTC — an hour wrong for half the year, in the direction nobody notices
until a maintenance window lands in business hours. It works on a developer
machine either way, which is exactly why it gets forgotten.
Skips are recorded and surfaced, not just logged: past the 1h grace window is
`missed`, an active run is `already_running`, and a schedule that no longer
parses is disabled rather than left spinning the loop every 30 seconds forever.
### Server tags and workflow targeting
A server carries `tags map[string]string` — lowercase `[a-z0-9_-]`, key ≤32,
value ≤64, 20 per server, `sys:` reserved. **There is no `tags` collection**: a
tag is a property of a server, not an entity, so `KnownTags` aggregates over
`servers` rather than reading a registry that would need reference counting to
know when a tag stopped existing. `PUT /api/servers/:id/tags` replaces the whole
map — last-write-wins over a small map beats merge semantics between two people
editing one server. The index is `{instance_id: 1, "tags.$**": 1}`, wildcard
because the queried key is chosen by the user at request time and cannot be named
in advance; `EnsureServerIndexes` warns rather than being fatal, since a missing
index degrades tag filtering to a scan of a small collection and is no reason to
refuse to serve the fleet list.
`services.ResolveTargets` is the **single** answer to which servers a workflow
touches — the run path and validation both go through it, so the readout and the
dispatch cannot disagree. It is the distinct union of `target_server_ids` and
`target_tags` (AND across keys), ordered by the fleet rather than by the
arguments, so two runs naming the same servers differently are still comparable
line by line. **An empty selector matches nothing** on purpose: "matches
everything" turns a cleared field in the designer into a fleet-wide run. Both
empty is `ErrNoTargets` (400), not a success over zero servers. Offline servers
are **not** filtered out — 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.
**Both halves of the selector are edited in `EditWorkflowModal`** — the named
servers in a `DualListBox`, the tag rows directly beneath it — and saved
together by one `updateWorkflow`. The designer's Targets panel is **read-only**:
it reports the count and the tags and links to Edit. Splitting the two halves
across two screens meant a workflow's reach was decided in two places with no
one view showing both.
`web/lib/targets.ts` **duplicates the match logic in TypeScript** to draw the
resolved count without a round trip, since the browser already holds the fleet.
It is a second implementation of `UnionTargets` / `MatchesTags` and must change
in the same commit as the Go one — the same shape of hazard as the mirrored
token blocks. It is a shared module rather than inline in a component because
the logic had already been written twice, and the second copy — the workflows
list — counted `target_server_ids` alone, so a **tag-only workflow reported zero
targets** while running fine.
The server picker is a hand-built two-pane list, not `<select multiple>`: a
native multi-select paints its selected rows with the platform highlight colour,
which cannot be restyled across browsers and lands outside the token palette on
a dark ground.
### Monitors
HTTP, TCP, ICMP and TLS checks. Each monitor has a `runner`: `"server"` (executed by the server-side scheduler) or a `server_id` (pushed to that agent, which runs it locally and reports results). Consecutive failures beyond `retries` flip state to `down`, open an `Incident`, and notify. Hourly `Rollup` documents back the uptime graphs.
@@ -150,6 +230,13 @@ server is behind NAT on a private address. It also means the console now
**requires a live agent** on every deployment: `consoleConnect` answers 409
`agent_offline` rather than hanging.
**guacd's Service is headless on purpose.** The server resolves `GUACD_ADDR` to
build the allow-list of sources permitted to claim a relay listener; a ClusterIP
resolves to the Service's virtual address while guacd connects from its *pod*
IP, so every relay connection is rejected and every session dies with
`waiting for guacd: i/o timeout`. Compose is immune — there the name resolves to
the address that connects.
SSH connections authenticate with a stored private key; RDP/VNC credentials are
encrypted, single-use, and consumed when the tunnel opens. None of them reach
the agent — the session is negotiated end-to-end between guacd and the target
@@ -171,10 +258,10 @@ rare cross-pod branch that only fails under load.
| Concern | How it crosses replicas |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Which pod owns an agent | `vantage:agent:<server_id>` holds the owner's node ID with a 30s TTL, renewed every 10s. `Dispatcher.IsConnected` is an `EXISTS` on it |
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue |
| Sending a command | published to `vantage:cmd:<server_id>`; the owner pod acks on `vantage:ack:<command_id>`. **Request/ack, not a queue** — a command whose owner died must fail loudly (503) rather than queue. The envelope carries `node`, the presence holder resolved at publish time, and a pod ignores envelopes addressed elsewhere: the channel is a fan-out, and during a reconnect a half-open stream's pod is still subscribed. Unaddressed, it could ack first and queue the command onto a dead stream — the operator told it worked, the agent never seeing it. Presence renewal is owner-only (`RenewPresence`) for the same reason: a blind `SET` let the stale pod steal the key back every 10s |
| Step results | the owner pod publishes to `vantage:res:<command_id>`; the pod driving the run subscribes **before** dispatching, or a fast agent answers into a channel nobody has joined |
| Step output | never crosses. The dispatch envelope carries the secret mask list, so the owner pod masks and writes lines itself — unmasked bytes stay off the bus |
| Console relay | the envelope asks the owner pod to bind the listener, and the ack returns **that pod's** address for guacd. The relay's failure reason comes back on `vantage:proxyend:<proxy_id>` |
| Console relay | **not routed to the owner pod at all.** A `ProxyStream` is its own HTTP/2 request and an L7 proxy balances requests, not connections, so it does not follow the command stream — the listener therefore cannot be bound in advance. Whichever pod receives the stream binds it and announces **its own** address on `vantage:proxyaddr:<proxy_id>`; `vantage:proxypending:<proxy_id>` (30s, consumed atomically) is what authorises the claim, and the failure reason comes back on `vantage:proxyend:<proxy_id>` |
| Background jobs | `bus.RunAsLeader` — one Redis lock named `housekeeping` |
**Workflow logs are in MongoDB** (`workflow_log_lines`, one document per line,
@@ -188,6 +275,25 @@ marker is written and the rest is dropped. Without that cap a `yes` in a step
is a database incident. **Nothing writes to `/data` any more**, which is why
`server.persistence` now defaults to off and `VANTAGE_WORKFLOW_LOG_DIR` is gone.
**Shutdown order is load-bearing.** `main` traps SIGTERM, stops gRPC
(`GracefulStop`, 10s cap) and only then drains HTTP. Each `CommandStream`
handler releases its agent's presence claim on return, so a killed process
leaves `vantage:agent:<server_id>` behind for the rest of its 30s TTL — during
which other replicas dispatch to a pod that has exited and the caller sees
`agent offline` for a perfectly healthy agent. Draining HTTP first would hold
those claims for the length of the drain, which is why gRPC goes first. The
chart's `server.terminationGracePeriodSeconds` (30s) must stay above the
10s + 10s the stop sequence needs, or the kubelet SIGKILLs mid-shutdown and the
handling buys nothing.
The agent side of the same failure: `runCommandStream` resets its backoff only
after a stream that survived `streamHealthyAfter`. `connectAndHandleStream`
returns an error on *every* stream end, healthy ones included, so without that
reset the backoff only ever climbed — an agent pinned itself at the ceiling
after a handful of ordinary deploys and stayed there. The ceiling is 30s, not
minutes, because while the stream is down the agent still polls `SyncKeys` and
still reads as `active` in the fleet list while answering no commands at all.
**The leader lock is not an optimisation.** N replicas each running the monitor
scheduler means each check fires N times, each incident notification reaches the
customer N times, and each hourly rollup is written N times; N reapers race to
@@ -209,10 +315,294 @@ 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
against distribution security feeds and raises findings that link to the
existing `ApplyUpdatesCmd` patching path. Gated by the `vuln_scanning` licence
feature, **checked at collection rather than display** — an ungated instance
stores no inventory, and storage is the expensive half.
**Matching uses distribution feeds, never NVD version ranges.** Distributions
backport security fixes without changing the upstream version: Ubuntu's
`openssl 3.0.2-0ubuntu1.15` is patched against CVE-2023-0286 while NVD still
calls 3.0.2 vulnerable. Matching on NVD would report a fully patched fleet as
critical, and once the first report is mostly wrong nobody reads the second.
`trivy-db` is those feeds pre-merged; `server/internal/vulndb` pulls it as an
OCI artifact to an ephemeral directory. Version comparison is bought from
`go-deb-version`/`go-rpm-version`/`go-apk-version` because dpkg epochs, `~`
sorting before the empty string, and `rpmvercmp` are each a silent false
negative waiting to happen.
**Only the leader matches.** `ReportPackages` upserts the list and sets
`scan_pending`; it does not scan. `vulnsched` runs inside the existing
`bus.RunAsLeader("housekeeping", …)` and does the matching, because otherwise
every replica needs the ~50MB database resident and a database refresh has N
replicas rescanning the same fleet and sending N digests. The tick is also the
digest's batch boundary, which is what makes "one message, not five hundred"
structural rather than a debounce someone maintains.
Findings are **never deleted when a package is patched** — the state moves to
`fixed`, so "what did we remediate last quarter" stays answerable. Acceptance
requires a reason and an expiry, and reopens automatically: permanent dismissal
is where risk goes to be forgotten. An unsupported distribution reports
`status: unsupported`, never "0 findings"; claiming clean when the truth is
unknown is the same lie as a silently stale database, which is why
`vulndb_meta.pulled_at` is on screen rather than only in a log.
**`server/Dockerfile`'s runtime stage is `scratch`, so it carries an explicitly
copied `/tmp`.** The scheduler unpacks the database to a temporary directory,
and a scratch image has none — the failure is `vulnsched: temp dir: stat /tmp:
no such file or directory`, logged once at boot while every other subsystem
runs normally, so the only symptom is a fleet that never reports a finding.
Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
scheduler off entirely.
### Workload registry
A **workload** is one Docker container or one systemd unit — one word for the
page, the collection and the commands, rather than saying "container or
service" in every identifier.
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`. Every
script that reports data emits JSON that a build-tag-free parser reads, so
those parsers are tested on Linux — the agent module has no Windows CI. The
control verbs and `serviceDisplayName` emit no JSON and have no parser; they
are exercised only by running the agent on Windows.
**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.
### Status pages
Two collections: `status_pages` is the page itself — title, banner, published
flag, and an ordered list of sections each holding entries that pair a
`monitor_id` with a per-page display name. `status_incidents` holds both
operator-authored incidents and maintenance windows, sharing one document
shape because they share a timeline, an impact and a set of affected
components; each carries an explicit `page_ids` rather than deriving it from
`affected_monitors`, because adding a monitor to a page later must not
retroactively republish that monitor's old incidents to a new audience.
**`services.assembleSnapshot` is the redaction boundary, and it is the only
one.** It takes a `snapshotInput` built from already-fetched
`models.Monitor`/`models.Rollup`/`models.Incident` documents and returns a
`StatusSnapshot` built entirely from a parallel, deliberately smaller
vocabulary (`PublicComponent`, `PublicIncident`, …) that has no field for a
target URL, host, port, expected status, keyword, failure message,
certificate expiry, latency, runner or notification channel — `models.Monitor`
itself never reaches an anonymous caller, only the handful of fields
`assembleSnapshot` chooses to copy out of it. Being a pure function of already-
fetched data (no DB calls inside it) is what makes the boundary testable
without a database, which is the only thing standing between an editor adding
a field to `PublicComponent` and that field being a hostname.
Monitor-detected outages are **derived at read time, never copied**: each
snapshot assembly reads recent `incidents` for the page's monitors and folds
them into the timeline alongside the authored ones. There is no second
incidents table for automatic ones and no reconciliation between two records
of the same outage. A maintenance window in progress **repaints how a day is
drawn, never the uptime number** — `buildDays` computes each day's up/down
state and the 90-day percentage from rollups first, and
`applyMaintenanceRepaint` only overwrites today's display state afterward, so
a component that stayed up throughout a maintenance window still shows as up
in its history.
The public route, `GET /public/status/:pageId`, is mounted on the gin **root**,
outside `/api`, on purpose: `/api` carries `auth.Middleware`, `RequireScopes`,
`RateLimitTokens` and `RequireActiveLicense` by virtue of where it is mounted,
and a public route living there would need four exemptions — each one a hole a
later change to any of those four could widen back open. A missing page, an
unpublished page, and a page on the wrong host all answer the same 404;
inventing a distinct code for "exists but unpublished" would itself leak that
the page exists. A lapsed licence or a tier lacking `status_pages` answers 200
with `available:false` and a `reason`, never a 403 or a blank page — the
reader is a member of the public who can do nothing about either condition and
deserves an explanation, not a browser error.
**The instance is resolved from `X-Forwarded-Host`, not `Host`.** The public
page is server-rendered by `web/`, and the SSR fetch cannot set `Host` at all:
it is a forbidden header name and undici drops it silently, so the Go server
saw `server:8080` and every status page 404'd on every deployment. `web/`
forwards the visitor's host in `X-Forwarded-Host` (and their address in
`X-Forwarded-For`, or the whole deployment shares one rate-limit bucket), and
`publicStatusInstance` honours that header **only when `c.RemoteIP()` is in
`TRUSTED_PROXIES`** — it selects a tenant, so an untrusted peer must not be
able to name one. It uses `RemoteIP()` and not `ClientIP()` deliberately: the
latter is reconstructed from the very headers being judged.
**A host naming no slug falls back to the sole instance on a non-cloud
deployment.** `hostSlug` requires `<slug>.vantage.<tld>`; a self-hosted install
at `vantage.acme.com` or an IP has no slug and would otherwise 404 forever. It
has exactly one instance, resolved with the same count-then-read bootstrap
uses, cached alongside the slug lookups. More than one instance is a 404, not a
guess. A host that *does* name a slug which does not exist stays a 404 —
falling back there would serve one tenant's page on another's address.
Assembled snapshots are cached in Redis for **30 seconds**, keyed per
instance and page, and every authoring write (`UpdateStatusPage`,
`DeleteStatusPage`, and every incident mutation) invalidates its page's entry
immediately rather than waiting out the TTL — an operator posting an update
mid-incident should not wonder for half a minute whether it saved. A cache
miss, on Redis being down or on any read error, degrades to reassembly rather
than an error: the status page has to survive the outage it exists to report.
The public endpoint itself is rate limited to **120 requests per minute per
client address**, answering 429 with `Retry-After`, on the same fixed-window
pattern as `RateLimitTokens`.
**`TRUSTED_PROXIES` is load-bearing for that limiter, not cosmetic.** `main.go`
always calls `gin.SetTrustedProxies` with it; left unset, gin trusts no proxy
and `c.ClientIP()` falls back to the direct peer address — which, sat behind a
real reverse proxy, is the proxy's own address for every visitor. The rate
limiter then keys on one address for the whole fleet of readers, and the first
burst of legitimate traffic during an incident is what trips it. Set it to the
proxy's real address or CIDR, not merely a private range guess; the shipped
compose file and Helm chart default it to the RFC1918 ranges, which is right
for their own bundled reverse proxy but wrong the moment another one is
inserted in front. The same setting also decides the address recorded in
`audit_logs` and `console_sessions`.
### 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 nine
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
`workloads`, `status`, `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.
@@ -330,13 +720,28 @@ 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
- **Bootstrap** — first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner.
- **Local auth** — email + password (bcrypt), `POST /auth/login`.
- **OIDC** — configured _per org_ (`org_oidc`), issuer + client ID + encrypted client secret. `/auth/oidc/start``/auth/oidc/callback`.
- **Auth providers** — configured _per instance_ in `auth_providers`, any number of them, each named and independently enabled. Issuer, client ID and an encrypted client secret per provider. `/auth/oidc/:providerId/start``/auth/oidc/:providerId/callback`. Presets (Entra, Google, Okta, GitHub) are a Go table in `server/internal/auth/presets.go` and expand to a real issuer on save, so nothing downstream knows a preset existed. GitHub is OAuth2 rather than OIDC and takes its own branch, requiring an address that is both primary **and** verified — an unverified address is not proof of control.
- **Local login**`settings.local_login_enabled`, a `*bool` because absent must mean enabled; a plain bool would disable password sign-in fleet-wide at upgrade. `services.CheckLockout` refuses any change leaving neither local login nor an enabled provider, and is enforced in the service layer so the settings path and the provider path cannot disagree.
- **Sessions** — opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL.
- **Roles**`owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
- **Host/org guard**`APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
@@ -360,6 +765,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);
@@ -369,7 +775,19 @@ 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`.
`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
treats 70s of silence as a dead stream and reconnects. Keepalive cannot do this
job behind an L7 proxy: the agent's HTTP/2 connection terminates at the proxy,
which answers pings on its own behalf, so a control-plane pod that dies leaves
the agent blocked in `Recv` on a stream that never delivers another message and
never errors — commands dispatched into it are silently lost while `SyncKeys`
keeps succeeding and the fleet list still shows the server `active`. The agent's
watchdog arms only **after** it has seen a first ping, so an older server that
sends none is treated as working rather than put into a reconnect loop.
Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions live in `proto/vantage/v1/vantage.proto`.
@@ -385,7 +803,8 @@ GET /install /install.ps1 # dynamic agent install scripts
GET /update /update.ps1
GET /auth/bootstrap-status
POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me /auth/oidc/start /auth/oidc/callback
GET /auth/me
GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} — no issuer, client ID or secret
GET /api/secrets/:group/values # bearer token (ESO)
```
@@ -408,12 +827,29 @@ channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:i
secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group
POST /secrets/:group/reveal · DELETE /secrets/:group/:key
console POST /console/connect · GET /console/tunnel (websocket)
vulns GET /vulnerabilities · GET /vulnerabilities/summary
POST /vulnerabilities/rescan (owner|admin)
POST,DELETE /vulnerabilities/:id/accept (owner|admin)
GET /servers/:id/vulnerabilities · GET /servers/:id/packages
GET /packages/search?name=
GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin)
workloads GET /workloads · GET /servers/:id/workloads
POST /servers/:id/workloads/refresh
POST /servers/:id/workloads/:wid/action (owner|admin)
GET /servers/:id/workloads/:wid/logs (owner|admin)
status-pages GET,POST /status-pages · GET,PUT,DELETE /status-pages/:pageId (owner|admin)
GET,POST /status-pages/:pageId/incidents
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
POST /status-pages/:pageId/incidents/:incidentId/updates
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
licence GET /license · POST /license (POST: self-hosted only)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
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.
@@ -445,11 +881,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
@@ -471,6 +907,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
@@ -483,11 +920,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` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
@@ -499,13 +936,21 @@ Notes that are not obvious from the structs:
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
- `auth_providers.provider_id` is a short random identifier, not the Mongo `_id`: it appears in the callback URL a customer pastes into their IdP, and an `_id` there would publish a database key. `callback_notice` marks a provider migrated from the old single-provider shape, whose redirect URI therefore changed.
- `workflow_log_lines` is keyed `(run_id, server_id, seq)` — the index is not an optimisation, every read is a range scan over it. `workflow_log_seq` holds one counter document per `run_id/server_id`, which is what lets two pods interleave into one ordered log. Neither carries `instance_id`: they are reached only through a run, and a run is already scoped.
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
- `server_packages` holds a server's whole package set in **one** document, not one per package. The hash already established that something changed, so a report is a single atomic upsert with no delta logic to get wrong; ~2000 packages is ~150KB, well inside the 16MB limit. `scan_pending` lives on the document rather than in memory so a leader handover cannot lose it.
- `vuln_findings` is unique on `(instance_id, server_id, cve_id, package_name)`. That key is what makes a rescan an idempotent upsert rather than a duplicate factory, and what lets `first_seen` survive one. An empty `fixed_in` means no vendor fix exists — a real state, never "not vulnerable".
- `vulndb_meta` is a singleton and deliberately carries **no** `instance_id`: the vulnerability database is a property of the deployment, not a tenant. Same reasoning as `migrations`, and the reason it is absent from `services.ScopedCollections`.
- **`services.ScopedCollections` is the canonical registry of tenant-scoped collections**, and `scopedCollectionsForPurge` derives instance deletion from it rather than keeping a second list. A new collection carrying `instance_id` must be added there or its rows outlive the instance.
- `api_tokens` stores only `sha256` of the token, like `servers.agent_token_hash`. A token's effective role is `min(user.role, token.role)` **recomputed per request**, so demoting somebody demotes their tokens; deleting the user deletes them. Scopes are enforced from a map keyed on the registered gin route pattern, and `AssertScopeMapComplete` **fails boot** when an `/api` route is missing from it — a route added without an entry would otherwise be silently unreachable by every token.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more `KindFeature` row per paid plan in `SeedCatalogue` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on `(kind, deployment, tier, feature_key)`, so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path.
### Migrations
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
@@ -513,6 +958,7 @@ Admin's own database is separate and holds `accounts` · `admin_instances` · `l
- `0001_default_org_backfill`
- `0002_settings_org_backfill` (must run before 0003 — 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch)
- `0003_missed_org_scopes`
- `0005_auth_providers` — copies each `instance_oidc` document into `auth_providers`, ciphertext verbatim rather than decrypted and re-encrypted, so it does not need `KEY_ENCRYPTION_KEY` and cannot strand an instance's SSO configuration that has none set.
Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn.
@@ -546,7 +992,7 @@ tls: true
```
1. SyncKeys(server_id, agent_token, agent_version)
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
2. Non-Linux hosts stop here — the key-management steps below are Linux-only; a Windows agent's other work (workflow steps, inventory, OS updates, workloads) runs from the goroutines started above, not from this loop
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
```
@@ -589,9 +1035,12 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
| `PROXY_ADVERTISE_HOST` | no | default `server`; the hostname guacd resolves the control plane by, handed to guacd as the relay's address. Wrong here and every console session fails at connect |
| `PROXY_LISTEN_HOST` | no | default `0.0.0.0`; the interface the ephemeral relay listener binds |
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `TRUSTED_PROXIES` | no | comma-separated CIDRs or addresses gin trusts for `X-Forwarded-For`. Empty means trust none: `c.ClientIP()` falls back to the direct peer address, which behind a real reverse proxy is that proxy's own address for every visitor — the public status page's per-address rate limit then keys on one address for the whole fleet of readers. Also the address recorded in `audit_logs` and `console_sessions`. Compose and the Helm chart default it to the RFC1918 ranges, right for their own bundled proxy and wrong the moment another one is inserted in front |
| `POD_IP` | no | this pod's own address, set by the Helm chart from the downward API. **Takes precedence over `PROXY_ADVERTISE_HOST`** — a console relay listener belongs to one replica, and a Service address names all of them |
| `VANTAGE_MIGRATE_ONLY` | no | run schema setup (migrations, index builders, default-step seeding) and exit without serving. `GRPC_HOST` is not required in this mode. Set by the Helm chart's pre-upgrade Job |
| `VANTAGE_SKIP_MIGRATIONS` | no | serve without running schema setup, on the assumption a Job already did. Set by the chart's Deployment whenever `server.migrationJob.enabled`. Unset under Compose, where one process still migrates and then serves |
| `VANTAGE_TRIVY_DB_REF` | no | default `ghcr.io/aquasecurity/trivy-db:2`. Point at a mirror for an air-gapped install, or to avoid the anonymous ghcr rate limit |
| `VANTAGE_VULNDB_DISABLED` | no | `true` disables the vulnerability database puller and scan loop entirely. Findings already written are still served, and still shown as stale |
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
**sitesvc** (`deploy/docker-compose.site.yml` only):
@@ -610,12 +1059,15 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
`ingress.enabled` publishes **two** hostnames, because the two audiences arrive over different protocols:
| Values | Route |
| --------------------- | ----------------------------------------------------------------------------------------- |
| `ingress.web.host` | browsers → `web:3000`. Everything, including `/api` — see below |
| `ingress.grpc.host` | agents → a dedicated `<release>-server-grpc` Service on 9090, annotated `serversscheme: h2c` |
| Values | Route |
| -------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `ingress.web.host` (+ `web.extraHosts`) | browsers → `web:3000` |
| `ingress.api.paths` (when `api.enabled`) | `/api`, `/auth``<release>-server:8080`, bypassing the Next proxy |
| `ingress.grpc.host` | agents → a dedicated `<release>-server-grpc` Service on 9090, annotated `serversscheme: h2c` |
**The server's HTTP port is deliberately not publishable.** `web` already proxies `/api`, `/auth` and the install scripts to it (`web/next.config.ts`), so a second route would be a second front door to the same API with none of that routing — and the console WebSocket and ESO token path would then exist at two addresses with different behaviour.
**`ingress.web.host` is normally a wildcard.** `*.vantage.example.com` is the per-tenant instance namespace — `APP_ROOT_LABEL` resolves the instance from the label. A Kubernetes wildcard host matches **exactly one** label, so it does not match the apex, and here that is correct rather than a gap: `vantage.hostxtra.co.uk` is the marketing site (`site/`, in `docker-compose.site.yml`), which this chart does not deploy. `extraHosts` is for a genuine second name; adding the apex to it would put the control plane on the marketing host. Every host in the list gets identical paths.
**`ingress.api.enabled` routes `/api`, `/auth`, `/public`, `/install*` and `/update*` straight to the server, and it is not optional.** It defaults to **true** and the chart refuses to render with it off, because `web` proxies nothing: with those prefixes unrouted the UI loads and every request it makes 404s against Next. The value survives only for an installation whose own terminator sits in front of this ingress and routes them there instead. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
**The gRPC route needs its own Service.** The server terminates no TLS; it speaks plain h2c and always has, with TLS terminated by whatever sits in front. Traefik will not use h2c to a backend unless the *Service* says so, and that annotation applies to every port on the Service — so annotating the shared two-port `<release>-server` would force h2c on its HTTP port too.
@@ -625,6 +1077,8 @@ TLS is `ingress.tls.secretName` / `grpcSecretName` (pre-existing certificates) *
---
**Neither compose file ships a reverse proxy, and both now need one.** `web:3000` serves the UI only; a request to `/api` there is a Next 404. Route `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` to `server:8080` and everything else to `web:3000` — on vantage.hostxtra.co.uk that is the Nginx Proxy Manager already in front, and it is what a self-hosted install has to configure before the UI works at all.
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk.
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
@@ -692,9 +1146,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
@@ -772,7 +1237,7 @@ git push origin main # server + web deploy
| `REGISTRY_USER` | Secret | Gitea username. Must own `RELEASE_TOKEN`, or basic auth is rejected |
| ~~`REGISTRY_PASSWORD`~~ | — | **Not used.** Named here historically; no workflow reads it. Referencing an unset secret yields an empty password and a `401 Failed to authenticate user` that looks like a token scope problem. Use `RELEASE_TOKEN` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
| ~~`API_URL`~~ | — | **Gone.** `web` proxies nothing and holds no address for the control plane. `/api`, `/auth`, `/public`, `/install*` and `/update*` must be routed to `server:8080` by the reverse proxy in front of both; everything else goes to `web:3000`. One variable that could name the wrong host was one request path too many — pointed at the marketing site, `/public/status/…` answered a Next 404 indistinguishable from a status page that does not exist. |
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
| `SITE_URL` | Variable | browser URL of the marketing site, baked into `adminsite` so `/login` can point at `/start`. **Signup has no page in `adminsite` at all** — one signup form, on `site/`. Empty renders no link rather than one that 404s. |
@@ -803,7 +1268,14 @@ 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: a
Windows agent never calls `ReportPackages`, so no `server_packages` document
exists for it and it reports no package inventory at all — a different,
earlier state than the `unsupported` a Linux distribution reaches when its
family has no security feed.
- **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.
+54 -91
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -8,15 +9,16 @@ 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"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"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
@@ -42,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})
}
@@ -207,76 +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,
}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-point the subscription from the placeholder id to the real UUID so
// billing.IssueForInstance (and every later webhook) finds it.
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": placeholderID},
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); 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})
}
+51 -41
View File
@@ -2,6 +2,7 @@ package billing
import (
"context"
"errors"
"fmt"
"time"
@@ -14,6 +15,7 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -67,9 +69,21 @@ func handleSubscription(ctx context.Context, ev Event) error {
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
}
// 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",
d.ID, d.CustomData.InstanceID, err)
}
sub := models.Subscription{
AccountID: d.CustomData.AccountID,
InstanceID: d.CustomData.InstanceID,
InstanceID: instanceID,
PaddleSubscriptionID: d.ID,
Tier: match.Tier,
Term: match.Term,
@@ -88,22 +102,20 @@ func handleSubscription(ctx context.Context, ev Event) error {
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
}
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
return fmt.Errorf("subscription %s names unknown instance %s: %w",
d.ID, d.CustomData.InstanceID, err)
}
// 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 {
@@ -116,6 +128,27 @@ func handleSubscription(ctx context.Context, ev Event) error {
return promoteAndIssue(ctx, &inst, match, reason)
}
// resolveInstance finds the instance a webhook's custom_data names, following the
// 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
err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": customDataID}).Decode(&inst)
if err == nil {
return inst.InstanceID, inst, nil
}
if !errors.Is(err, mongo.ErrNoDocuments) {
return "", inst, err
}
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"previous_instance_ids": customDataID}).Decode(&inst); err != nil {
return "", inst, err
}
return inst.InstanceID, inst, nil
}
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
// licence from granted. This is the only promotion path other than the staff
// grant, and it exists because a webhook is a confirmed payment.
@@ -217,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 {
@@ -280,9 +289,10 @@ 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 {
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" {
// 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 == "" {
return "your instance"
}
return 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)
}
+57 -1
View File
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"fmt"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -57,6 +59,48 @@ func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*mod
return &inst, nil
}
// RepointSubscriptions follows an instance identity rewrite: it moves every
// subscription row from the old id to the new one, then rewrites Paddle's copy
// of custom_data so future webhooks decode to the new id.
//
// 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 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.
func RepointSubscriptions(ctx context.Context, oldID, newID, accountID string) error {
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}}); err != nil {
return fmt.Errorf("repoint %s -> %s: %w", oldID, newID, err)
}
cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"instance_id": newID})
if err != nil {
log.Printf("repoint %s -> %s: read subscriptions: %v", oldID, newID, err)
return nil
}
var subs []models.Subscription
if err := cur.All(ctx, &subs); err != nil {
log.Printf("repoint %s -> %s: decode subscriptions: %v", oldID, newID, err)
return nil
}
for _, s := range subs {
if s.PaddleSubscriptionID == "" {
continue
}
// Paddle replaces the whole custom_data object on a PATCH, so account_id
// is sent alongside rather than dropped.
if err := paddle.Get().UpdateSubscriptionCustomData(ctx, s.PaddleSubscriptionID,
map[string]string{"account_id": accountID, "instance_id": newID}); err != nil {
log.Printf("repoint %s -> %s: patch custom_data on %s: %v",
oldID, newID, s.PaddleSubscriptionID, err)
}
}
return nil
}
// Relink moves a licence to a rebuilt server's new UUID.
//
// The replacement covers the REMAINING term, not a fresh one — relinking is not
@@ -96,13 +140,25 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": oldID},
bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
bson.M{
"$set": bson.M{"instance_id": newID},
"$inc": bson.M{"relink_count": 1},
"$addToSet": bson.M{"previous_instance_ids": oldID},
}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, ErrAlreadyLinked
}
return nil, fmt.Errorf("relink: %w", err)
}
// 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
}
actor := accountID
if staff {
actor = "staff"
-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 {
+69
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
@@ -12,6 +13,7 @@ import (
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
@@ -210,6 +212,73 @@ func Backfill(ctx context.Context) error {
if err := backfillEntitlements(ctx); err != nil {
return err
}
// Pass 6: instances whose identity was rewritten before previous_instance_ids
// existed carry no trail, and Paddle's custom_data still names the id they
// were rewritten FROM — so their next webhook resolves to nothing. Both
// rewrites wrote an audit entry naming the old id, which is the only surviving
// record of it, so reconstruct the trail from those.
if err := backfillInstanceIDHistory(ctx); err != nil {
return err
}
return nil
}
// backfillInstanceIDHistory rebuilds previous_instance_ids from the audit entries
// the two identity rewrites leave behind: a placeholder claim
// ("instance.placeholder_linked", detail "from placeholder <id>") and a relink
// ("instance.relinked", detail "was <id>").
//
// $addToSet is what makes it idempotent, and it also means a chain of relinks
// accumulates rather than the last one winning. Entries are walked NEWEST first,
// matching on the current id or an already-recovered one: an instance relinked
// A→B→C answers to neither A nor B by the time this runs, so the C entry has to
// record B before the B entry has anything to attach A to.
func backfillInstanceIDHistory(ctx context.Context) error {
prefixes := map[string]string{
"instance.placeholder_linked": "from placeholder ",
"instance.relinked": "was ",
}
actions := make(bson.A, 0, len(prefixes))
for action := range prefixes {
actions = append(actions, action)
}
cur, err := db.Admin("admin_audit").Find(ctx,
bson.M{"action": bson.M{"$in": actions}},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
return err
}
var entries []AuditEntry
if err := cur.All(ctx, &entries); err != nil {
return err
}
recorded := 0
for _, e := range entries {
prefix := prefixes[e.Action]
if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) {
continue
}
oldID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
if oldID == "" || oldID == e.Target {
continue
}
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"$or": bson.A{
bson.M{"instance_id": e.Target},
bson.M{"previous_instance_ids": e.Target},
}},
bson.M{"$addToSet": bson.M{"previous_instance_ids": oldID}})
if err != nil {
return err
}
recorded += int(res.ModifiedCount)
}
if recorded > 0 {
log.Printf("backfill: recovered %d instance id rewrites from the audit log", recorded)
}
return nil
}
+6 -1
View File
@@ -67,7 +67,10 @@ func (r CatalogueRow) Priced(env string) bool {
return false
}
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
// SeedCatalogue inserts the twenty-four rows the four PAID plans need: a base, a
// server limit, and one row per feature key. The count is deliberate — it moves
// whenever shared/license gains a feature, and this comment is how the next
// person knows the number was chosen rather than drifted.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
@@ -84,6 +87,8 @@ func SeedCatalogue(ctx context.Context) error {
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureStatusPages},
}
for _, r := range rows {
filter := bson.M{
+27 -4
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,16 +146,30 @@ 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 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.
PreviousInstanceIDs []string `bson:"previous_instance_ids,omitempty" json:"-"`
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
// remembered so the confirmed-payment webhook can provision the instance with
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
+4
View File
@@ -25,6 +25,10 @@ type Client interface {
// immediately by Paddle. This is the one outbound mutation, used when a
// 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 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)
// Env is "sandbox" or "production", the same value catalogue price lookups
+11
View File
@@ -99,6 +99,17 @@ func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string,
}, nil)
}
// UpdateSubscriptionCustomData patches custom_data only. Paddle replaces the
// whole object, so callers pass every key they want to keep.
func (c *httpClient) UpdateSubscriptionCustomData(ctx context.Context, subID string, data map[string]string) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, struct {
CustomData map[string]string `json:"custom_data"`
}{CustomData: data}, nil)
}
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
if customerID == "" {
return "", fmt.Errorf("paddle: empty customer id")
+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 -96
View File
@@ -2,16 +2,64 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { API_BASE, ApiError, NotConnected, api, type License } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { RenamePanel } from "@/components/RenamePanel";
import { StatePill } from "@/components/StatePill";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { TermBar } from "@/components/TermBar";
import { EmptyState, Note, Panel } from "@/components/Panel";
import { PageFrame, RailCard } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features";
import { useSession } from "@/lib/session";
/** One key/value row. The key is the same keyed idiom as everywhere else. */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4">
<dt className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{label}</dt>
<dd className="m-0 text-[0.88rem] tabular-nums">{value}</dd>
</div>
);
}
/*
* Every feature the product sells, granted or not.
*
* Listing only what is included answers "what do I have" but not "what am I
* missing", which is the question someone on this screen is actually weighing
* before they click Change plan. The absent ones are struck through rather than
* omitted, so the comparison is on the page instead of in another tab.
*/
function Features({ granted }: { granted: string[] }) {
const all = Object.keys(FEATURE_LABEL);
// Anything the licence carries that this build does not know about is still
// shown — the map degrades to the raw key, which is ugly but never wrong.
const extras = granted.filter((f) => !all.includes(f));
return (
<div className="grid gap-2">
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Features</span>
<div className="flex flex-wrap gap-1.5">
{[...all, ...extras].map((f) => {
const on = granted.includes(f);
return (
<span key={f} title={featureDesc(f) || undefined} className={on ? "rounded-sm border border-rule px-2 py-0.5 text-[0.78rem] text-ink-2" : "rounded-sm border border-rule-soft px-2 py-0.5 text-[0.78rem] text-ink-3 line-through decoration-ink-3/60"}>
{featureLabel(f)}
</span>
);
})}
</div>
</div>
);
}
export default function InstancePage() {
const id = String(useParams().id);
@@ -19,6 +67,10 @@ export default function InstancePage() {
const qc = useQueryClient();
const [relinkError, setRelinkError] = useState<string | undefined>();
// useSession is the app's one way to ask who the caller is — it shares the
// ["me"] query, so this adds no request.
const { session } = useSession();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
const licence = useQuery({
queryKey: ["license", id],
@@ -28,12 +80,11 @@ export default function InstancePage() {
const relink = useMutation({
mutationFn: (newId: string) => api.relink(id, newId),
onSuccess: (lic) => {
onSuccess: (lic: License) => {
qc.invalidateQueries({ queryKey: ["account"] });
router.replace(`/instances/${lic.instance_id}`);
},
onError: (err) =>
setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
onError: (err) => setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
});
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
@@ -50,117 +101,176 @@ export default function InstancePage() {
const lic = licence.data;
const state = licenceState(lic?.expires_at, Boolean(lic));
const cloud = instance.deployment === "cloud";
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
const maxRelinks = account.data?.max_relinks ?? 3;
const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null;
return (
<div className="grid gap-6">
<PageHeader
back={{ href: "/", label: "Overview" }}
title={instance.name || "Unnamed instance"}
subtitle={`${cloud ? "Cloud" : "Self-hosted"}${
instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""
} · created ${formatDate(instance.created_at)}`}
record={[
{ key: "Instance", value: instance.instance_id, copy: true },
...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : []),
]}
subtitle={`${cloud ? "Cloud" : "Self-hosted"} instance${instance.tier ? ` on ${instance.tier.replace("_", " ")}` : ""} · created ${formatDate(instance.created_at)}`}
/*
* The two things this screen is for, in the header rather than
* hunted for further down. Download is self-hosted only: a cloud
* licence is injected into the control plane directly and there
* is nothing for the customer to do with the file.
*/
actions={
<>
{lic && !cloud && (
<LinkButton variant="line" external href={api.licenseBlobUrl(instance.instance_id)}>
Download licence
</LinkButton>
)}
{lic && <LinkButton href="/purchase">Renew licence</LinkButton>}
</>
}
record={[{ key: "Instance", value: instance.instance_id, copy: true }, ...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : [])]}
status={<StatePill state={state} />}
/>
<PageFrame
aside={
<>
<RailCard title="Licence">
{lic ? (
<RailFacts
rows={[
{ label: "Tier", value: lic.tier.replace("_", " ") },
{ label: "Issued", value: formatDate(lic.issued_at) },
{ label: "Expires", value: formatDate(lic.expires_at) },
{ label: "Reason", value: lic.reason },
]}
/>
) : (
<p className="text-[0.82rem] text-ink-2">
No licence issued yet.
</p>
)}
host ? (
<RailCard title="Console">
<p className="text-[0.82rem] text-ink-2">Servers, workflows and monitors live in the instance itself.</p>
<a href={`https://${host}`} className="inline-flex items-center justify-center gap-2 rounded border border-rule px-3 py-2 text-[0.84rem] font-semibold text-ink no-underline hover:border-accent hover:text-accent">
Open {instance.name || "instance"} &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",
value: lic.features.join(", ") || "none",
},
]}
/>
</RailCard>
)}
{!cloud && (
<RailCard title="Moves">
<RailFacts
rows={[
{
label: "Relinks used",
value: `${instance.relink_count} of ${
account.data?.max_relinks ?? 3
}`,
},
]}
/>
<p className="text-[0.82rem] text-ink-2">
Moving a licence to a different install counts as one.
</p>
</RailCard>
)}
</>
) : undefined
}
>
{cloud ? (
<MembersPanel instanceId={instance.instance_id} />
) : (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
Users for this install are managed inside it, in Settings Instance. We do
not have access to your own deployment.
</p>
{/*
* The term leads. This screen is about one licence, and the rail
* carried its issue and expiry dates as two lines of text
* which is the arithmetic this bar does for the reader.
*/}
{lic && (
<Panel title="Licence" meta={`${lic.tier.replace("_", " ")} · ${cloud ? "Cloud" : "Self-hosted"}`}>
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
{state === "warn" && <Note tone="warn">Inside 14 days of expiry. Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.</Note>}
{state === "expired" && <Note tone="expired">A lapsed licence does not stop the control plane: agents carry on reporting and your servers keep their keys. It stops accepting changes, so nothing new can be deployed until this is renewed.</Note>}
</Panel>
)}
{lic && !cloud && (
<>
<LicenceDelivery
instanceId={instance.instance_id}
blob={lic.blob ?? ""}
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
{/*
* What the licence grants, on the screen about that licence.
* These were four rows in a 320px rail card, which is where
* facts go when nobody has decided they matter.
*/}
{lic && (
<Panel
title="Included"
/* A panel-header action is a quiet link, not a second
full-size button competing with the header's Renew. */
actions={
<Link href="/purchase" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
Change plan &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));
@@ -6,25 +6,16 @@ import Link from "next/link";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
import { featureDesc, featureLabel } from "@/lib/features";
/* Tiers in the order a customer reads them, cheapest first. */
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Human labels for feature keys. The catalogue names them by key; this is the
* one place the customer-facing wording lives. */
const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
};
const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
};
function featureLabel(key: string) {
return FEATURE_LABEL[key] ?? key;
}
/* Feature wording lives in lib/features.ts, shared with the staff
* configurator. It was duplicated here and there, and the two copies had
* already drifted. */
interface Choice {
tier: Tier;
@@ -151,10 +142,13 @@ export function PurchaseForm() {
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the licence."),
});
// Self-hosted checkout names the install's REAL UUID, so the instance is
// linked (or an already-owned one reused) before Paddle opens. The webhook
// then issues straight onto it — there is no placeholder to claim afterwards.
const startCheckout = useMutation({
mutationFn: async () => {
const trimmed = name.trim();
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHosted(trimmed);
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHostedCheckout(uuid.trim(), trimmed);
return r.instance_id;
},
onSuccess: async (instanceId) => {
@@ -168,12 +162,6 @@ export function PurchaseForm() {
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start checkout."),
});
const claim = useMutation({
mutationFn: () => api.claimLink(pending!.instanceId, uuid.trim()),
onSuccess: () => router.push("/"),
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link the install."),
});
if (optionsQ.isLoading || account.isLoading) {
return <p className="text-ink-3">Loading plans</p>;
}
@@ -294,7 +282,7 @@ export function PurchaseForm() {
{featureKeys.map((key) => {
const st = featureStateFor(plan, rows, options.env, choice.term, key);
return (
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
{st === "included" ? (
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
) : st === "absent" ? (
@@ -317,11 +305,13 @@ export function PurchaseForm() {
</Block>
)}
{selfHostedFree && (
<Block n={3} label="Your install">
{dep === "self_hosted" && (
<Block n={paid ? 4 : 3} label="Your install">
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
<p className="text-[0.86rem] text-ink-2">
Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence nothing to pay.
{paid
? "Every licence binds to one install, so stand your control plane up first and paste the instance ID it reports. We attach it to your account now and the licence lands the moment payment clears. Already have an instance here? Paste its ID to upgrade it."
: "Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence — nothing to pay."}
</p>
<label className="grid gap-1">
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
@@ -389,7 +379,7 @@ export function PurchaseForm() {
) : (
<Cta
label={startCheckout.isPending ? "Starting…" : "Continue to payment"}
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending}
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending || (dep === "self_hosted" && !UUID_RE.test(uuid.trim()))}
onClick={() => {
setError(null);
startCheckout.mutate();
@@ -398,28 +388,13 @@ export function PurchaseForm() {
))}
{/* Phase B: after the checkout has been opened. */}
{pending?.deployment === "self_hosted" && (
{pending && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="text-[0.8rem] text-ink-2">Once payment clears, paste the instance ID your install reports (Settings Licence) to receive your licence.</p>
<input
value={uuid}
onChange={(e) => setUuid(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
/>
<Cta
label={claim.isPending ? "Linking…" : "Link and issue licence"}
disabled={!uuid.trim() || claim.isPending}
onClick={() => {
setError(null);
claim.mutate();
}}
/>
</div>
)}
{pending?.deployment === "cloud" && (
<div className="grid gap-2 border-t border-rule-soft pt-3">
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears no further steps.</p>
<p className="text-[0.8rem] text-ink-2">
{pending.deployment === "cloud"
? "Your instance is being set up. Its licence appears the moment payment clears — no further steps."
: "Your install is attached to this account. Its licence appears the moment payment clears — no further steps."}
</p>
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
Go to your instance
</Link>
+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>
);
}
+2 -1
View File
@@ -2,6 +2,7 @@
import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
import { featureLabel } from "@/lib/features";
export interface PlanChoice {
tier: Tier;
@@ -159,7 +160,7 @@ export default function PlanConfigurator({
})
}
/>
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
<span>{featureLabel(key)}</span>
<span className="text-[0.72rem] text-ink-3">
{priced ? "paid add-on" : "included"}
</span>
+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"),
+29
View File
@@ -0,0 +1,29 @@
/* Human wording for licence feature keys.
*
* One place, because there were two and they disagreed: the staff configurator
* rendered every key that was not "console" as "Single sign-on", so adding a
* third feature silently mislabelled the checkbox that grants it. A map with a
* fallback degrades to the raw key, which is ugly but never wrong.
*
* Keys must match shared/license/license.go. */
export const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
vuln_scanning: "Vulnerability scanning",
status_pages: "Status pages",
};
export const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
vuln_scanning: "Package inventory matched against distribution security advisories",
status_pages: "Public status pages for your customers, built from your monitors",
};
export function featureLabel(key: string): string {
return FEATURE_LABEL[key] ?? key;
}
export function featureDesc(key: string): string {
return FEATURE_DESC[key] ?? "";
}
+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}`;
}
+5
View File
@@ -18,6 +18,10 @@ const (
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
// out. Match on a prefix, not equality: the version moves.
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
)
@@ -84,6 +88,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
if err != nil {
return Result{Message: err.Error()}
}
req.Header.Set("User-Agent", UserAgent)
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
-20
View File
@@ -1,20 +0,0 @@
package config
import (
"runtime"
"strings"
"testing"
)
func TestConfigDirByOS(t *testing.T) {
d := ConfigDir()
if runtime.GOOS == "windows" {
if !strings.Contains(strings.ToLower(d), "programdata") {
t.Fatalf("windows config dir = %q, want ProgramData path", d)
}
} else {
if d != "/etc/vantage" {
t.Fatalf("unix config dir = %q, want /etc/vantage", d)
}
}
}
+33 -2
View File
@@ -80,7 +80,11 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
return resp.AgentToken, nil
}
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
// SyncKeys returns the whole response rather than just the keys: the poll now
// also carries CollectPackages, and a second RPC purely to learn one boolean
// would be a message every 30 seconds for a value that changes at most when a
// licence does.
func (c *Client) SyncKeys(serverID, agentToken, version string) (*pb.SyncResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -92,7 +96,34 @@ func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error
if err != nil {
return nil, err
}
return resp.PublicKeys, nil
return resp, nil
}
// ReportPackages sends a package report and returns whether the server wants
// the full list. Given a longer deadline than the other unary calls because the
// full body is ~150KB on a slow link.
func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := c.client.ReportPackages(ctx, req)
if err != nil {
return false, err
}
return resp.NeedFull, nil
}
// ReportWorkloads sends a workload report and returns whether the server wants
// the full list.
func (c *Client) ReportWorkloads(req *pb.ReportWorkloadsRequest) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := c.client.ReportWorkloads(ctx, req)
if err != nil {
return false, err
}
return resp.NeedFull, nil
}
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
+77 -30
View File
@@ -1,5 +1,3 @@
package pb
import (
@@ -30,6 +28,45 @@ type SyncRequest struct {
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
// CollectPackages tells the agent whether this instance's licence grants
// vulnerability scanning. Absent decodes as false, which is the safe
// direction: an older server leaves agents collecting nothing.
CollectPackages bool `json:"collect_packages,omitempty"`
}
type OSRelease struct {
Family string `json:"family"`
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
// versions for the same CVE, so a scan without it is guesswork.
VersionId string `json:"version_id"`
Arch string `json:"arch,omitempty"`
}
type InstalledPackage struct {
Name string `json:"name"`
Version string `json:"version"`
Epoch int32 `json:"epoch,omitempty"`
Arch string `json:"arch,omitempty"`
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
// against "openssl" covers libssl3, openssl and libssl-dev.
SourceName string `json:"source_name,omitempty"`
}
// ReportPackagesRequest carries a server's installed package set.
//
// The agent calls twice at most: first with Packages empty, offering only the
// hash. If the server already holds it, NeedFull is false and the ~150KB body
// is never sent.
type ReportPackagesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Hash string `json:"hash"`
Os OSRelease `json:"os"`
Packages []InstalledPackage `json:"packages,omitempty"`
}
type ReportPackagesResponse struct {
NeedFull bool `json:"need_full"`
}
type UploadKeyRequest struct {
@@ -44,8 +81,6 @@ type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
@@ -60,8 +95,6 @@ type ReportUpdatesRequest struct {
type ReportUpdatesResponse struct{}
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
@@ -80,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"`
@@ -166,9 +198,17 @@ type ServerCommand struct {
RunStep *RunStepCmd `json:"run_step,omitempty"`
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
// no reply: its arrival is the entire message. See the .proto for why gRPC
// keepalive is not sufficient on its own.
type PingCmd struct{}
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
@@ -192,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{}
@@ -213,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"`
}
@@ -233,8 +274,6 @@ type StepOutputChunk struct {
Eof bool `json:"eof,omitempty"`
}
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
@@ -257,8 +296,6 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
return m, nil
}
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
@@ -331,6 +368,8 @@ type VantageClient interface {
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
@@ -390,6 +429,14 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
return out, nil
}
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
out := new(ReportPackagesResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
out := new(InventoryReportResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
+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
}
+63
View File
@@ -0,0 +1,63 @@
package packages
import (
"bufio"
"errors"
"io"
"os"
"runtime"
"strings"
)
// OSRelease identifies the distribution well enough to select an advisory
// feed. VersionID is not optional: Ubuntu 22.04 and 24.04 publish different
// fixed versions for the same CVE.
type OSRelease struct {
Family string
VersionID string
Arch string
}
// ParseOSRelease reads the os-release format: KEY=value, one per line, with
// values optionally quoted, and # comments.
//
// The quote stripping handles both ID=ubuntu and ID="rocky", which real
// distributions both emit.
func ParseOSRelease(r io.Reader) (OSRelease, error) {
out := OSRelease{Arch: runtime.GOARCH}
sc := bufio.NewScanner(r)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
val = strings.Trim(strings.TrimSpace(val), `"'`)
switch strings.TrimSpace(key) {
case "ID":
out.Family = strings.ToLower(val)
case "VERSION_ID":
out.VersionID = val
}
}
if err := sc.Err(); err != nil {
return OSRelease{}, err
}
if out.Family == "" {
return OSRelease{}, errors.New("os-release has no ID")
}
return out, nil
}
// DetectOS reads /etc/os-release.
func DetectOS() (OSRelease, error) {
f, err := os.Open("/etc/os-release")
if err != nil {
return OSRelease{}, err
}
defer f.Close()
return ParseOSRelease(f)
}
+73
View File
@@ -0,0 +1,73 @@
package packages
import (
"context"
"fmt"
"os/exec"
"runtime"
"time"
)
const collectTimeout = 2 * time.Minute
// Collect enumerates installed packages. Linux only: Windows agents are
// second-class by design, and vulnerability scanning there needs a different
// source, a different collector and a different matcher, all out of scope.
//
// The format strings below are raw string literals on purpose. The "\t" and
// "\n" reach dpkg-query and rpm as two characters each, and those tools do the
// interpreting themselves — Go must not consume the escapes first.
func Collect() (OSRelease, []Package, error) {
if runtime.GOOS != "linux" {
return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS)
}
osrel, err := DetectOS()
if err != nil {
return OSRelease{}, nil, fmt.Errorf("detect os: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
defer cancel()
switch {
case have("dpkg-query"):
out, err := run(ctx, "dpkg-query", "-W", "-f",
`${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n`)
if err != nil {
return osrel, nil, err
}
return osrel, ParseDpkg(out), nil
case have("rpm"):
out, err := run(ctx, "rpm", "-qa", "--qf",
`%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`)
if err != nil {
return osrel, nil, err
}
return osrel, ParseRPM(out), nil
case have("apk"):
out, err := run(ctx, "apk", "info", "-v")
if err != nil {
return osrel, nil, err
}
return osrel, ParseAPK(out), nil
default:
return osrel, nil, fmt.Errorf("no supported package manager found")
}
}
func have(bin string) bool {
_, err := exec.LookPath(bin)
return err == nil
}
func run(ctx context.Context, name string, args ...string) (string, error) {
out, err := exec.CommandContext(ctx, name, args...).Output()
if err != nil {
return "", fmt.Errorf("%s: %w", name, err)
}
return string(out), nil
}
+159
View File
@@ -0,0 +1,159 @@
package packages
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
)
// Package is one installed package as the distribution reports it. Version is
// the distribution's own version string, verbatim — never normalised, because
// the advisory feeds are keyed on exactly this form.
type Package struct {
Name string
Version string
Epoch int
Arch string
SourceName string
}
// ParseDpkg reads tab-separated output of
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n'
//
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
// alone finds one of the three.
//
// The fifth column is why "rc" packages do not appear. dpkg-query -W lists
// every package dpkg knows about, including ones removed with their config
// files left behind — a host that has upgraded its kernel a dozen times reports
// a dozen old linux-modules versions that are not on disk, and the oldest of
// them sorts first and reads as the installed version. Only "installed" is
// installed. An empty status means dpkg did not understand the field, in which
// case the line is kept rather than the whole inventory silently vanishing.
func ParseDpkg(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(line, "\t")
if len(f) < 3 {
continue
}
if len(f) > 4 {
if s := strings.TrimSpace(f[4]); s != "" && s != "installed" {
continue
}
}
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
if len(f) > 3 && f[3] != "" {
p.SourceName = f[3]
} else {
p.SourceName = p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}
// ParseRPM reads tab-separated output of
// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n'
func ParseRPM(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(line, "\t")
if len(f) < 4 {
continue
}
epoch := 0
// rpm prints "(none)" rather than omitting the field when a package has
// no epoch. That must become 0, not fail the line.
if f[1] != "" && f[1] != "(none)" {
if n, err := strconv.Atoi(f[1]); err == nil {
epoch = n
}
}
p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]}
if len(f) > 4 {
p.SourceName = srcRPMName(f[4])
}
if p.SourceName == "" {
p.SourceName = p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}
// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping
// the trailing ".src.rpm" and then the version and release segments, which are
// the last two hyphen-separated fields.
func srcRPMName(s string) string {
s = strings.TrimSuffix(s, ".src.rpm")
parts := strings.Split(s, "-")
if len(parts) <= 2 {
return s
}
return strings.Join(parts[:len(parts)-2], "-")
}
// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line.
// Alpine has no separate source package, so SourceName mirrors Name.
func ParseAPK(out string) []Package {
var pkgs []Package
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, version := splitAPK(line)
if name == "" {
continue
}
pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name})
}
return pkgs
}
// splitAPK finds the version boundary from the RIGHT. The version is always the
// last two hyphen-separated fields ("<version>-r<rev>"), which is reliable
// where scanning from the left is not: package names legitimately contain
// digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be
// found by looking for the first digit.
func splitAPK(s string) (name, version string) {
last := strings.LastIndex(s, "-")
if last <= 0 {
return "", ""
}
prev := strings.LastIndex(s[:last], "-")
if prev <= 0 {
return "", ""
}
return s[:prev], s[prev+1:]
}
// Hash fingerprints a package set so an unchanged set never has to be sent.
//
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
// and an ordering-sensitive hash would resend the full ~150KB list every hour
// for no reason — a cost visible only as traffic.
func Hash(pkgs []Package) string {
lines := make([]string, 0, len(pkgs))
for _, p := range pkgs {
lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch)
}
sort.Strings(lines)
h := sha256.New()
for _, l := range lines {
h.Write([]byte(l))
h.Write([]byte("\n"))
}
return hex.EncodeToString(h.Sum(nil))
}
+125
View File
@@ -0,0 +1,125 @@
package agentsync
import (
"context"
"log"
"runtime"
"sync"
"sync/atomic"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
)
// collectPackagesFlag is written by the 30s key poll and read by the hourly
// package loop — two different goroutines, hence the atomic.
//
// It defaults to false, so an agent that has not yet completed a poll, or is
// talking to a server too old to send the field, collects nothing. Off is the
// safe default: collecting without a licence costs the customer storage they
// are not paying for.
var collectPackagesFlag atomic.Bool
// firstPoll closes once a SyncKeys response has set the flag above.
//
// Without it the boot-time package report loses a race it can only lose: the
// hourly loop starts before the first poll, reads a flag that is still false by
// construction, and skips — so a freshly installed agent reports no packages for
// an hour and the server shows nothing to scan.
// How long the boot package report waits for that first poll. Two poll
// intervals plus slack: long enough to cover one failed attempt, short enough
// that a dead control plane does not hold the OS-update report hostage.
const firstPollWait = 90 * time.Second
var (
firstPoll = make(chan struct{})
firstPollOnce sync.Once
)
func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
// bounded because this loop also reports OS updates, which do not depend on the
// flag at all — a control plane that cannot be polled must not silence those too.
func waitFirstPoll(ctx context.Context, limit time.Duration) {
t := time.NewTimer(limit)
defer t.Stop()
select {
case <-firstPoll:
case <-t.C:
log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
case <-ctx.Done():
}
}
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
// reportPackages offers a hash of the installed package set and sends the full
// list only if the server does not already hold it.
//
// It runs on the same hourly cadence as the update check because a package set
// changes on roughly the same schedule, and reusing that loop means one timer
// rather than two.
func reportPackages(client *grpcclient.Client, cfg *config.Config) {
if runtime.GOOS != "linux" {
return
}
if !collectPackagesEnabled() {
return
}
osrel, pkgs, err := packages.Collect()
if err != nil {
log.Printf("package collection error: %v", err)
return
}
pbOS := pb.OSRelease{
Family: osrel.Family,
VersionId: osrel.VersionID,
Arch: osrel.Arch,
}
hash := packages.Hash(pkgs)
// The offer: hash only, no body. On an unchanged host this is the whole
// exchange, which is the point of the handshake.
needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Hash: hash,
Os: pbOS,
})
if err != nil {
log.Printf("ReportPackages offer error: %v", err)
return
}
if !needFull {
return
}
pbPkgs := make([]pb.InstalledPackage, len(pkgs))
for i, p := range pkgs {
pbPkgs[i] = pb.InstalledPackage{
Name: p.Name,
Version: p.Version,
Epoch: int32(p.Epoch),
Arch: p.Arch,
SourceName: p.SourceName,
}
}
if _, err := client.ReportPackages(&pb.ReportPackagesRequest{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Hash: hash,
Os: pbOS,
Packages: pbPkgs,
}); err != nil {
log.Printf("ReportPackages full error: %v", err)
return
}
log.Printf("reported %d installed packages", len(pkgs))
}
+194 -18
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)
@@ -92,11 +94,19 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
}
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
resp, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
if err != nil {
return fmt.Errorf("SyncKeys: %w", err)
}
// Stored atomically: the hourly package loop reads this from another
// goroutine. Absent on the wire decodes as false, so an older server leaves
// collection off rather than on.
collectPackagesFlag.Store(resp.CollectPackages)
markFirstPoll()
desired := resp.PublicKeys
if runtime.GOOS != "linux" {
return nil
}
@@ -118,9 +128,36 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return nil
}
// How long a command stream must survive before it counts as having worked.
// Past this, the next drop is treated as a fresh incident rather than as the
// continuation of a run of failures.
const streamHealthyAfter = time.Minute
// Stream staleness. The server beats every 20s, so 70s tolerates three missed
// beats before the stream is written off — high enough that a slow network or a
// briefly busy server does not cost a reconnect, low enough that an agent is
// not uncommandable for minutes after a control-plane restart.
const (
streamStaleAfter = 70 * time.Second
streamStaleCheck = 10 * time.Second
// How often a healthy stream reports itself. Also the interval at which an
// agent talking to a control plane too old to send heartbeats says so —
// that agent is running without a watchdog, and the journal should not be
// silent about it.
pingSummaryInterval = 5 * time.Minute
)
func runCommandStream(ctx context.Context, cfg *config.Config) {
backoff := time.Second
const maxBackoff = 2 * time.Minute
// Two minutes was the old ceiling, and it was reached far too easily. The
// command stream is what makes this agent controllable at all: while it is
// down, workflows and console sessions fail as "agent offline" even though
// SyncKeys keeps polling happily and the fleet list still shows the server
// active. A shorter ceiling costs a few reconnect attempts; the old one cost
// two minutes of an agent that looks fine and answers nothing.
const maxBackoff = 30 * time.Second
for {
select {
@@ -129,22 +166,45 @@ func runCommandStream(ctx context.Context, cfg *config.Config) {
default:
}
if err := connectAndHandleStream(ctx, cfg); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
}
} else {
started := time.Now()
err := connectAndHandleStream(ctx, cfg)
if ctx.Err() != nil {
return
}
// A stream that stayed up is evidence the control plane is reachable,
// whatever ended it. Without this the backoff only ever climbed:
// connectAndHandleStream returns an error on *every* stream end,
// including a healthy one dropped by a routine deploy, so an agent
// pinned itself at the ceiling after a handful of ordinary restarts and
// stayed there for the rest of its life.
if time.Since(started) >= streamHealthyAfter {
backoff = time.Second
}
// The uptime is in the line because it is what distinguishes a stream
// that never worked from one that ran for hours and was dropped by a
// deploy — and it is the same measure that decides whether the backoff
// resets, so a reader can see why the delay is what it is.
up := time.Since(started).Truncate(time.Second)
if err != nil {
log.Printf("command stream error after %s: %v, reconnecting in %s", up, err, backoff)
} else {
log.Printf("command stream closed after %s, reconnecting in %s", up, backoff)
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
@@ -155,7 +215,13 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
}
defer client.Close()
stream, err := client.CommandStream(ctx)
// Cancelling this context is what unblocks Recv when the stream has gone
// quiet. Without it the watchdog below would have no way to interrupt a
// read that is never going to return.
streamCtx, abandon := context.WithCancel(ctx)
defer abandon()
stream, err := client.CommandStream(streamCtx)
if err != nil {
return fmt.Errorf("open stream: %w", err)
}
@@ -168,7 +234,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
return fmt.Errorf("send auth: %w", err)
}
log.Println("command stream connected")
log.Printf("command stream connected to %s", cfg.ServerURL)
var sendMu sync.Mutex
send := func(msg *pb.AgentMessage) error {
@@ -177,11 +243,93 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
return stream.Send(msg)
}
// Stream liveness, tracked here rather than left to gRPC keepalive.
//
// Keepalive operates on the transport, and behind an L7 proxy the transport
// ends at the proxy: it answers pings whether or not the server behind it
// is still running. A control-plane pod that dies therefore leaves this
// agent blocked in Recv on a stream that will never deliver another message
// and never error, while the control plane dispatches commands into it and
// the operator watches nothing happen.
//
// The watchdog only arms once a ping has actually been seen. A server too
// old to send them must not be treated as dead — that would put the agent
// in a reconnect loop against a control plane that is working perfectly.
var (
lastMu sync.Mutex
lastRecv = time.Now()
pinged bool
beats int
)
markRecv := func(isPing bool) {
lastMu.Lock()
lastRecv = time.Now()
if isPing {
beats++
// Logged once per stream, because it is the moment the agent starts
// holding the control plane to account: before this the watchdog is
// disarmed and a dead stream would go unnoticed indefinitely.
if !pinged {
pinged = true
log.Printf("command stream heartbeat detected, watchdog armed (%s threshold)", streamStaleAfter)
}
}
lastMu.Unlock()
}
go func() {
t := time.NewTicker(streamStaleCheck)
defer t.Stop()
// Reported periodically rather than per beat: at one every 20s the
// journal would be nothing else. The count is what makes a partial
// failure visible — beats arriving but fewer than expected is a
// different problem from beats stopping altogether.
summary := time.NewTicker(pingSummaryInterval)
defer summary.Stop()
for {
select {
case <-streamCtx.Done():
return
case <-summary.C:
lastMu.Lock()
n, armed := beats, pinged
beats = 0
lastMu.Unlock()
if armed {
log.Printf("command stream healthy, %d heartbeats in the last %s", n, pingSummaryInterval)
} else {
log.Printf("command stream up but sending no heartbeats; "+
"control plane predates them, watchdog stays disarmed (last message %s ago)",
time.Since(lastRecv).Truncate(time.Second))
}
case <-t.C:
lastMu.Lock()
idle, armed := time.Since(lastRecv), pinged
lastMu.Unlock()
if armed && idle > streamStaleAfter {
log.Printf("command stream silent for %s (threshold %s), assuming it is dead and reconnecting",
idle.Truncate(time.Second), streamStaleAfter)
abandon()
return
}
}
}
}()
for {
cmd, err := stream.Recv()
if err != nil {
return fmt.Errorf("recv: %w", err)
}
markRecv(cmd.Ping != nil)
// Pings carry nothing and are not acknowledged; being received is their
// whole purpose.
if cmd.Ping != nil {
continue
}
if cmd.GenerateKey != nil {
go handleGenerateKey(cfg, cmd)
@@ -201,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) {
@@ -257,8 +414,17 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
return
}
log.Printf("reported %d available OS updates", len(pkgs))
// Same hourly cadence, same connection. A package set changes on
// roughly the schedule available updates do, so this needs no timer of
// its own.
reportPackages(client, cfg)
}
// The boot round only: after this the flag has long been set, and every
// later tick is an hour past a poll that runs every 30s.
waitFirstPoll(ctx, firstPollWait)
doCheck()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -284,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)
}
}
+35
View File
@@ -0,0 +1,35 @@
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 {
// Checked before the ExitError/stderr branch: CommandContext kills the
// process on timeout, and that kill can itself produce an ExitError
// carrying stderr text, so a genuine timeout would otherwise surface
// as that stderr instead of the "timed out" message callers match on.
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("powershell: timed out")
}
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
}
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,50 @@
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 | ForEach-Object {
[pscustomobject]@{
Name = $_.Name
DisplayName = $_.DisplayName
State = $_.State
StartMode = $_.StartMode
PathName = $_.PathName
ExitCode = $_.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")
}
+224
View File
@@ -0,0 +1,224 @@
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
}
// The wire shape is shared with the systemd collector — both report
// under kind "unit" — so the state word has to be too, or the UI
// (which colours and filters on it, and does so before it knows
// which platform sent the row) needs two vocabularies for one kind.
// running/stopped/failed become active/inactive/failed to match.
state := "inactive"
switch {
case running:
state = "active"
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
}
}
// Collapse every newline form, not just "\r\n": a message containing a
// bare "\n" would otherwise still break the one-line-per-event shape
// this renders for the log dialog, and undercount the tail trim above.
msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M))
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 ""
}
+207
View File
@@ -0,0 +1,207 @@
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 != "active" {
t.Errorf("Contoso = %+v", w)
}
// Enabled but not running is exactly the row worth seeing.
if byID["Fabrikam"].State != "inactive" {
t.Errorf("Fabrikam state = %q, want inactive", 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 != "inactive" {
t.Fatalf("got %+v, want one inactive 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 || got[0].State != "active" {
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)
}
}
// A message containing a bare "\n" (no carriage return) must still collapse to
// one line, or it silently multiplies into several output lines and throws
// off the tail trim's count.
func TestParseEventsCollapsesBareLF(t *testing.T) {
in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
if strings.Count(got, "\n") != 0 {
t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got)
}
want := "2026-08-13T10:00:00Z Error broker died stack trace here"
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.
@@ -0,0 +1,12 @@
{
"kind": "vantage.step/v1",
"name": "Apply Package Updates",
"description": "Apply all pending OS package updates. Supports apt, dnf, yum, zypper, apk and pacman.",
"interpreter": "bash",
"script": "set -u\nif command -v apt-get >/dev/null 2>&1; then\n export DEBIAN_FRONTEND=noninteractive\n apt-get update -qq && apt-get -y -qq upgrade\nelif command -v dnf >/dev/null 2>&1; then\n dnf -y upgrade\nelif command -v yum >/dev/null 2>&1; then\n yum -y update\nelif command -v zypper >/dev/null 2>&1; then\n zypper --non-interactive update\nelif command -v apk >/dev/null 2>&1; then\n apk update && apk upgrade\nelif command -v pacman >/dev/null 2>&1; then\n pacman -Syu --noconfirm\nelse\n echo \"no supported package manager found\"\n exit 1\nfi\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"package update failed\"\n exit 1\nfi\necho \"packages up to date\"\n# Debian and Ubuntu drop this file when a new kernel or libc needs a restart.\n# Reported rather than acted on: rebooting a fleet is a decision, not a detail.\nif [ -f /var/run/reboot-required ]; then\n echo \"REBOOT_REQUIRED=true\" >> $WORKFLOW_ENV\n echo \"a reboot is required to finish applying updates\"\nelse\n echo \"REBOOT_REQUIRED=false\" >> $WORKFLOW_ENV\nfi",
"declared_outputs": [
"REBOOT_REQUIRED"
],
"declared_inputs": [],
"secret_refs": []
}
+23
View File
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Check Port Is Listening",
"description": "Fail unless something is listening on a TCP port.",
"interpreter": "bash",
"script": "set -u\nhost=\"${host:-127.0.0.1}\"\nif command -v nc >/dev/null 2>&1; then\n nc -z -w 5 \"$host\" \"$port\" >/dev/null 2>&1\n ok=$?\nelse\n # bash builds /dev/tcp in, so this needs nothing installed.\n timeout 5 bash -c \"cat < /dev/null > /dev/tcp/$host/$port\" >/dev/null 2>&1\n ok=$?\nfi\nif [ $ok -ne 0 ]; then\n echo \"PORT_OPEN=false\" >> $WORKFLOW_ENV\n echo \"nothing listening on $host:$port\"\n exit 1\nfi\necho \"PORT_OPEN=true\" >> $WORKFLOW_ENV\necho \"$host:$port is open\"",
"declared_outputs": [
"PORT_OPEN"
],
"declared_inputs": [
{
"name": "host",
"default": "127.0.0.1",
"description": "host to test"
},
{
"name": "port",
"default": "",
"description": "TCP port to test"
}
],
"secret_refs": []
}
+23
View File
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Copy File/Directory",
"description": "Copy a file or directory, preserving mode, ownership and timestamps.",
"interpreter": "bash",
"script": "set -u\nif [ ! -e \"$source\" ]; then\n echo \"source $source does not exist\"\n exit 1\nfi\ncp -a \"$source\" \"$destination\" || { echo \"failed to copy $source to $destination\"; exit 1; }\necho \"copied $source to $destination\"\necho \"DEST_PATH=$destination\" >> $WORKFLOW_ENV",
"declared_outputs": [
"DEST_PATH"
],
"declared_inputs": [
{
"name": "source",
"default": "",
"description": "path to copy from"
},
{
"name": "destination",
"default": "",
"description": "path to copy to"
}
],
"secret_refs": []
}
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Create Directory",
"description": "Create a directory, including any missing parents.",
"interpreter": "bash",
"script": "set -u\nmkdir -p \"$path\" || { echo \"failed to create $path\"; exit 1; }\nif [ -n \"${mode:-}\" ]; then\n chmod \"$mode\" \"$path\" || { echo \"failed to set mode $mode on $path\"; exit 1; }\nfi\necho \"created $path\"\necho \"DIR_PATH=$path\" >> $WORKFLOW_ENV",
"declared_outputs": [
"DIR_PATH"
],
"declared_inputs": [
{
"name": "path",
"default": "",
"description": "directory to create"
},
{
"name": "mode",
"default": "",
"description": "optional octal mode, e.g. 0750"
}
],
"secret_refs": []
}
+16
View File
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Delete File/Directory",
"description": "Delete a path. Refuses the root filesystem and an empty value.",
"interpreter": "bash",
"script": "set -u\n# A step that runs as root on every server in a selector has to refuse the\n# one input that would wipe the fleet. An unset variable expands to empty,\n# so the empty case is the accident this actually guards against.\ncase \"$path\" in\n \"\"|\"/\"|\"/.\"|\"/..\")\n echo \"refusing to delete '$path'\"\n exit 1\n ;;\nesac\nif [ ! -e \"$path\" ]; then\n echo \"$path does not exist, nothing to do\"\n exit 0\nfi\nrm -rf \"$path\" || { echo \"failed to delete $path\"; exit 1; }\necho \"deleted $path\"",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "",
"description": "path to delete"
}
],
"secret_refs": []
}
+24
View File
@@ -0,0 +1,24 @@
{
"kind": "vantage.step/v1",
"name": "Disk Usage Report",
"description": "Report usage for a mount point and fail past a threshold.",
"interpreter": "bash",
"script": "set -u\nmount=\"${mountPoint:-/}\"\nlimit=\"${maxPercent:-90}\"\ndf -h \"$mount\"\nused=$(df --output=pcent \"$mount\" | tail -1 | tr -dc \"0-9\")\navail=$(df -h --output=avail \"$mount\" | tail -1 | tr -d \" \")\necho \"DISK_USED_PERCENT=$used\" >> $WORKFLOW_ENV\necho \"DISK_AVAILABLE=$avail\" >> $WORKFLOW_ENV\nif [ \"$used\" -ge \"$limit\" ]; then\n echo \"$mount is ${used}% full, at or over the ${limit}% limit\"\n exit 1\nfi\necho \"$mount is ${used}% full, ${avail} available\"",
"declared_outputs": [
"DISK_USED_PERCENT",
"DISK_AVAILABLE"
],
"declared_inputs": [
{
"name": "mountPoint",
"default": "/",
"description": "mount point to measure"
},
{
"name": "maxPercent",
"default": "90",
"description": "fail at or above this percentage"
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Docker Compose Pull and Up",
"description": "Pull the latest images for a compose project and recreate its containers.",
"interpreter": "bash",
"script": "set -u\ncd \"$projectDir\" || { echo \"no such directory: $projectDir\"; exit 1; }\nif docker compose version >/dev/null 2>&1; then\n dc=\"docker compose\"\nelif command -v docker-compose >/dev/null 2>&1; then\n dc=\"docker-compose\"\nelse\n echo \"docker compose is not installed\"\n exit 1\nfi\n$dc pull || { echo \"pull failed\"; exit 1; }\n$dc up -d --remove-orphans || { echo \"up failed\"; exit 1; }\n$dc ps",
"declared_outputs": [],
"declared_inputs": [
{
"name": "projectDir",
"default": "",
"description": "directory holding docker-compose.yml"
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Download File (Linux)",
"description": "Download a file over HTTP to a local path",
"interpreter": "bash",
"script": "out=$(mktemp -p ./)\necho \"Downloading file from $url\"\nwget -q $url -O $out\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to download file from url\"\n exit 1\nfi\necho \"FILE_PATH=$out\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": ["FILE_PATH"],
"declared_inputs": [
{
"name": "url",
"default": "",
"description": ""
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Enable Linux Service",
"description": "Enable a systemd unit so it starts on boot.",
"interpreter": "bash",
"script": "set -u\necho \"enabling service $serviceName\"\nsystemctl enable \"$serviceName\" || { echo \"failed to enable $serviceName\"; exit 1; }\necho \"$serviceName enabled\"",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": "systemd unit to enable"
}
],
"secret_refs": []
}
@@ -0,0 +1,23 @@
{
"kind": "vantage.step/v1",
"name": "Extract Archive",
"description": "Extract a tar, tar.gz, tar.bz2, tar.xz or zip archive into a directory.",
"interpreter": "bash",
"script": "set -u\ndest=\"${destination:-.}\"\nif [ ! -f \"$archive\" ]; then\n echo \"archive $archive does not exist\"\n exit 1\nfi\nmkdir -p \"$dest\"\ncase \"$archive\" in\n *.tar.gz|*.tgz) tar -xzf \"$archive\" -C \"$dest\" ;;\n *.tar.bz2|*.tbz2) tar -xjf \"$archive\" -C \"$dest\" ;;\n *.tar.xz|*.txz) tar -xJf \"$archive\" -C \"$dest\" ;;\n *.tar) tar -xf \"$archive\" -C \"$dest\" ;;\n *.zip)\n command -v unzip >/dev/null 2>&1 || { echo \"unzip is not installed\"; exit 1; }\n unzip -oq \"$archive\" -d \"$dest\"\n ;;\n *)\n echo \"unsupported archive type: $archive\"\n exit 1\n ;;\nesac\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to extract $archive\"\n exit 1\nfi\necho \"extracted $archive into $dest\"\necho \"EXTRACT_DIR=$dest\" >> $WORKFLOW_ENV",
"declared_outputs": [
"EXTRACT_DIR"
],
"declared_inputs": [
{
"name": "archive",
"default": "",
"description": "archive file to extract"
},
{
"name": "destination",
"default": ".",
"description": "directory to extract into"
}
],
"secret_refs": []
}
+9 -11
View File
@@ -1,12 +1,10 @@
{
"kind": "vantage.step/v1",
"name": "Get Host Name",
"description": "",
"interpreter": "bash",
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": [
"HOSTNAME"
],
"declared_inputs": [],
"secret_refs": []
}
"kind": "vantage.step/v1",
"name": "Get Host Name",
"description": "Gets the agents hostname",
"interpreter": "bash",
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": ["HOSTNAME"],
"declared_inputs": [],
"secret_refs": []
}
@@ -0,0 +1,33 @@
{
"kind": "vantage.step/v1",
"name": "HTTP Health Check",
"description": "Request a URL and fail unless it answers with the expected status.",
"interpreter": "bash",
"script": "set -u\nexpected=\"${expectedStatus:-200}\"\nattempts=\"${retries:-3}\"\ndelay=\"${retryDelay:-5}\"\nstatus=\"\"\ni=1\n# Retries live in the script rather than in on_failure: a service coming up\n# after a restart wants a few seconds, not a whole step re-dispatched.\nwhile [ \"$i\" -le \"$attempts\" ]; do\n status=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$url\" || echo \"000\")\n echo \"attempt $i: $url returned $status\"\n if [ \"$status\" = \"$expected\" ]; then\n break\n fi\n i=$(( i + 1 ))\n if [ \"$i\" -le \"$attempts\" ]; then sleep \"$delay\"; fi\ndone\necho \"HTTP_STATUS=$status\" >> $WORKFLOW_ENV\nif [ \"$status\" != \"$expected\" ]; then\n echo \"$url returned $status, expected $expected\"\n exit 1\nfi\necho \"$url is healthy\"",
"declared_outputs": [
"HTTP_STATUS"
],
"declared_inputs": [
{
"name": "url",
"default": "",
"description": "URL to request"
},
{
"name": "expectedStatus",
"default": "200",
"description": "HTTP status that counts as healthy"
},
{
"name": "retries",
"default": "3",
"description": "how many attempts before failing"
},
{
"name": "retryDelay",
"default": "5",
"description": "seconds between attempts"
}
],
"secret_refs": []
}
+15 -15
View File
@@ -1,16 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "List Directory",
"description": "",
"interpreter": "bash",
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "./",
"description": ""
}
],
"secret_refs": []
}
"kind": "vantage.step/v1",
"name": "List Directory",
"description": "Lists the files in the specified path",
"interpreter": "bash",
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "./",
"description": ""
}
],
"secret_refs": []
}

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