Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee1f9f3b32 | ||
|
|
5326639918 | ||
|
|
3bf80a117b | ||
|
|
189a8fa963 | ||
|
|
7b13944c24 | ||
|
|
e7384d334a | ||
|
|
be299845ca | ||
|
|
d83061786c | ||
|
|
b28a2263bb | ||
|
|
b6f45390c4 | ||
|
|
2e4bc687d4 | ||
|
|
d328d3aaca | ||
|
|
89cc524f3f | ||
|
|
f0f2600bed | ||
|
|
884fd189fb | ||
|
|
e08cc2f928 | ||
|
|
3ce963b0cd | ||
|
|
461a79277d | ||
|
|
d9f7fa6993 | ||
|
|
060fa64339 | ||
|
|
30d83c4c32 | ||
|
|
a918b1bdc1 | ||
|
|
66b1a041ba | ||
|
|
d55ed2b19a | ||
|
|
8135c8d781 | ||
|
|
577b060b8a | ||
|
|
1028a2e43a | ||
|
|
eba93a812e | ||
|
|
83b7256b60 | ||
|
|
9d17f539b5 | ||
|
|
28f746c7e2 | ||
|
|
9d218cb19f | ||
|
|
f9ef9c4929 | ||
|
|
049e005873 | ||
|
|
c440b59b93 | ||
|
|
3e4865884c | ||
|
|
270d55e6a6 | ||
|
|
7a0a1953f6 | ||
|
|
3e4ccc9720 | ||
|
|
e5947489e4 | ||
|
|
0a7a10aeed | ||
|
|
28b813ba64 | ||
|
|
68160dc681 | ||
|
|
32e7420d89 | ||
|
|
7e1d67dba4 | ||
|
|
da1dc90ac5 | ||
|
|
72c9492223 | ||
|
|
fa67d839cd | ||
|
|
1452928b75 | ||
|
|
bf10023f35 | ||
|
|
f4f41e400b | ||
|
|
3abbdc41d6 | ||
|
|
6ba54f690c | ||
|
|
a3c6b2a305 | ||
|
|
21a2d077d8 | ||
|
|
6263c7e16f | ||
|
|
161835802d | ||
|
|
6f998ff506 | ||
|
|
d192589790 | ||
|
|
21c2bb2646 | ||
|
|
9c0bbd13dd | ||
|
|
1c15961309 | ||
|
|
383b763a66 | ||
|
|
3e99a9df33 | ||
|
|
f1b6f90345 | ||
|
|
2a660697c5 | ||
|
|
0c08dda635 | ||
|
|
22b99ff895 | ||
|
|
2fab784ba7 | ||
|
|
83cdf92575 | ||
|
|
aa1c8e4aa1 | ||
|
|
ac61015cc0 | ||
|
|
a0fbf5b9ba | ||
|
|
ddf0814803 |
@@ -9,10 +9,21 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-docker
|
||||
container: node:26
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# vantage-shared is a private module, so the Go builds below cannot
|
||||
# resolve it without a credential.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -57,10 +68,20 @@ jobs:
|
||||
msi:
|
||||
needs: build
|
||||
runs-on: windows-2022
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Same private-module credential as the build job, in the file
|
||||
# Windows Go looks for: _netrc in the profile directory, not .netrc.
|
||||
- name: Write the module fetch credential
|
||||
shell: pwsh
|
||||
run: |
|
||||
"machine gitea.hostxtra.co.uk`nlogin ${{ secrets.REGISTRY_USER }}`npassword ${{ secrets.RELEASE_TOKEN }}" |
|
||||
Out-File -Encoding ascii "$env:USERPROFILE\_netrc"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -76,6 +76,13 @@ jobs:
|
||||
fi
|
||||
echo "ok: reaper configured in cloud mode only"
|
||||
|
||||
- name: Render with backups enabled
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.image=gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest \
|
||||
--set backup.pvcName=vantage-backups > /dev/null
|
||||
|
||||
- name: Render against external Redis and MongoDB
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
@@ -140,10 +147,21 @@ jobs:
|
||||
--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 \
|
||||
--set ingress.grpc.host=agents.example.com
|
||||
refuses "backup enabled with no pvcName" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.image=gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:latest
|
||||
refuses "backup enabled with no image" \
|
||||
--set backup.enabled=true \
|
||||
--set backup.pvcName=vantage-backups
|
||||
|
||||
- name: Read the chart version
|
||||
id: chart
|
||||
|
||||
@@ -71,15 +71,22 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
# The three Go images build from the repo root and COPY
|
||||
# shared/ plus their own directory, so shared/ rebuilds all
|
||||
# three. proto/ is in server's list as insurance: the
|
||||
# generated pb is committed under server/, but a proto change
|
||||
# that someone regenerates in the same push should not depend
|
||||
# on that ordering.
|
||||
flag server '^(server/|shared/|proto/|default_steps/|go\.work)'
|
||||
flag sitesvc '^(sitesvc/|shared/|go\.work)'
|
||||
flag admin '^(admin/|shared/|go\.work)'
|
||||
# shared/ is gone from this repository: it is the private
|
||||
# module gitea.hostxtra.co.uk/vantage/vantage-shared, pinned
|
||||
# per service in its own go.mod. A change over there reaches
|
||||
# a service when somebody bumps that pin, which is a commit
|
||||
# under the service's own directory and so already matches
|
||||
# below. There is no longer a directory whose change fans out
|
||||
# to three images, and no longer a way to ship a service
|
||||
# against a shared/ it was never built with.
|
||||
#
|
||||
# proto/ stays in server's list as insurance: the hand-written
|
||||
# pb now lives in vantage-shared, but a proto change made here
|
||||
# in the same push should not depend on the pin being bumped
|
||||
# in that same push to trigger a rebuild.
|
||||
flag server '^(server/|proto/|default_steps/|go\.work)'
|
||||
flag sitesvc '^(sitesvc/|go\.work)'
|
||||
flag admin '^(admin/|go\.work)'
|
||||
|
||||
# The three Next images and the docs site use their own
|
||||
# directory as the build context, so nothing outside it can
|
||||
@@ -89,6 +96,17 @@ jobs:
|
||||
flag adminsite '^adminsite/'
|
||||
flag docsite '^docsite/'
|
||||
|
||||
# vantage-shared is private, so every Go build below needs a
|
||||
# credential for it. A netrc is written once here rather than a
|
||||
# token being passed as a build arg, which would survive in the
|
||||
# builder layer's history.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_TOKEN }}" | \
|
||||
@@ -102,13 +120,22 @@ jobs:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: server/go.sum
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
|
||||
- name: Verify the OpenAPI document is current
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
run: |
|
||||
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
|
||||
cd server
|
||||
swag init --generalInfo cmd/main.go --dir ./,../shared \
|
||||
# swag reads Go source, not the import graph, so it needs the
|
||||
# shared module's files on disk. They are in the module cache
|
||||
# now rather than at ../shared, and the cache is read-only,
|
||||
# which swag does not mind.
|
||||
SHARED_DIR="$(go list -m -f '{{.Dir}}' gitea.hostxtra.co.uk/vantage/vantage-shared)"
|
||||
swag init --generalInfo cmd/main.go --dir "./,$SHARED_DIR" \
|
||||
--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
|
||||
@@ -117,8 +144,8 @@ jobs:
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
|
||||
# Root context: server depends on the shared module.
|
||||
docker build -t "$IMAGE" -f server/Dockerfile .
|
||||
docker build --secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "$IMAGE" -f server/Dockerfile .
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push web image
|
||||
@@ -147,16 +174,16 @@ jobs:
|
||||
if: steps.changed.outputs.sitesvc == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
|
||||
# Root context: sitesvc depends on the shared module.
|
||||
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
|
||||
docker build --secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "$IMAGE" -f sitesvc/Dockerfile sitesvc/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push admin image
|
||||
if: steps.changed.outputs.admin == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/admin:latest"
|
||||
# Root context: admin depends on the shared module.
|
||||
docker build -t "$IMAGE" -f admin/Dockerfile .
|
||||
docker build --secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "$IMAGE" -f admin/Dockerfile admin/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push adminsite image
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: vantagectl Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "vantagectl/v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-docker
|
||||
container: node:26
|
||||
env:
|
||||
GOPRIVATE: gitea.hostxtra.co.uk/*
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# vantage-shared is a private module, so the Go builds below cannot
|
||||
# resolve it without a credential.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: vantagectl/go.sum
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#vantagectl/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Test
|
||||
working-directory: vantagectl
|
||||
run: go test ./...
|
||||
|
||||
- name: Build
|
||||
working-directory: vantagectl
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
for target in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do
|
||||
goos="${target%/*}"
|
||||
goarch="${target#*/}"
|
||||
out="dist/vantagectl-${goos}-${goarch}"
|
||||
if [ "$goos" = "windows" ]; then out="${out}.exe"; fi
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o "$out" .
|
||||
done
|
||||
|
||||
- name: Checksums
|
||||
working-directory: vantagectl/dist
|
||||
run: sha256sum vantagectl-* > checksums.txt
|
||||
|
||||
- name: Create release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
files: |
|
||||
vantagectl/dist/vantagectl-linux-amd64
|
||||
vantagectl/dist/vantagectl-linux-arm64
|
||||
vantagectl/dist/vantagectl-darwin-arm64
|
||||
vantagectl/dist/vantagectl-windows-amd64.exe
|
||||
vantagectl/dist/checksums.txt
|
||||
|
||||
# The image is built here rather than in server-deploy.yml on every push to
|
||||
# main, because vantagectl is a released tool rather than a running service.
|
||||
# An operator restoring a database should be able to name the version they
|
||||
# ran; ":latest, rebuilt whenever main moved" cannot be named after the
|
||||
# fact. It is a separate job from the binaries because it needs a
|
||||
# docker-capable runner rather than a Go one, and it does not need the
|
||||
# binaries — the image builds from source in its own stage.
|
||||
image:
|
||||
runs-on: ubuntu-docker
|
||||
container: docker:dind
|
||||
steps:
|
||||
- name: Setup
|
||||
run: apk add --update nodejs npm git
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
# v0.1.0 for the binary stamp, 0.1.0 for the image tag: a
|
||||
# leading v is conventional on a git tag and unconventional on
|
||||
# a container tag.
|
||||
VERSION="${GITHUB_REF_NAME#vantagectl/}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "IMAGE_TAG=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
# The image build resolves the private vantage-shared module, so
|
||||
# it needs a credential of its own — this job does not share the
|
||||
# build job's filesystem.
|
||||
- name: Write the module fetch credential
|
||||
run: |
|
||||
umask 077
|
||||
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
|
||||
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
|
||||
> "$HOME/.netrc"
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_TOKEN }}" | \
|
||||
docker login ${{ vars.DOCKER_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push image
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
IMAGE_TAG: ${{ steps.version.outputs.IMAGE_TAG }}
|
||||
run: |
|
||||
REPO="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/vantagectl"
|
||||
# VERSION is passed through so `vantagectl --version` inside the
|
||||
# image reports the tag it was built from rather than "dev".
|
||||
# The context is vantagectl/ now that shared/ is an external
|
||||
# module rather than a sibling directory.
|
||||
docker build \
|
||||
--build-arg VERSION="${VERSION}" \
|
||||
--secret id=netrc,src="$HOME/.netrc" \
|
||||
-t "${REPO}:${IMAGE_TAG}" \
|
||||
-t "${REPO}:latest" \
|
||||
-f vantagectl/Dockerfile vantagectl/
|
||||
docker push "${REPO}:${IMAGE_TAG}"
|
||||
docker push "${REPO}:latest"
|
||||
@@ -91,7 +91,7 @@ vantage/
|
||||
│ └── models/ # accounts, instances, licences, plans
|
||||
├── adminsite/ # staff + customer console (vantage-hq)
|
||||
│ ├── app/(customer)/ # overview, instance, link, billing
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, pricing, audit
|
||||
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
|
||||
│ └── lib/ # api client, session guards, formatters
|
||||
├── docsite/ # user documentation (Docusaurus, static)
|
||||
@@ -99,17 +99,47 @@ vantage/
|
||||
│ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-*
|
||||
│ ├── sidebars.ts # authored by hand, not autogenerated
|
||||
│ └── nginx.conf # serves the build under /docs
|
||||
├── shared/ # imported by server, sitesvc and admin
|
||||
│ ├── mail/ # the one email system: transport + tmpl templates
|
||||
│ ├── license/ # payload, sign, verify, trusted keys, plans
|
||||
│ ├── models/ # Instance, User, Settings
|
||||
│ └── cmd/lkctl/ # issue and inspect licences by hand
|
||||
├── proto/vantage/v1/vantage.proto
|
||||
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
|
||||
├── deploy/ # docker-compose.yml, agent.service
|
||||
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
|
||||
```
|
||||
|
||||
**`shared/` is not in this repository.** It is the private module
|
||||
`gitea.hostxtra.co.uk/vantage/vantage-shared`, and it holds `mail/` (the one
|
||||
email system: transport plus templates), `license/` (payload, sign, verify,
|
||||
trusted keys, plans), `models/` (Instance, User, Settings), `provision/`,
|
||||
`backup/`, `cryptobox/`, `indexes/`, `grpc/pb` + `grpc/codec`, and
|
||||
`cmd/lkctl/`. Five modules here depend on it — `server`, `agent`, `admin`,
|
||||
`sitesvc`, `vantagectl` — each pinning a version in its own `go.mod`. It was a
|
||||
directory in this repository until it was extracted with its history; the
|
||||
`replace ../shared` directives and the `./shared` entry in `go.work` are gone
|
||||
with it.
|
||||
|
||||
**A version pin is now the coupling, and that is the point.** While it was a
|
||||
directory, every service in a given commit built against exactly one `shared/`,
|
||||
and a change there rebuilt three images at once whether or not they were ready
|
||||
for it. Now a service moves when somebody bumps its pin, which is a commit under
|
||||
that service's own directory — so the existing per-directory rebuild triggers
|
||||
already cover it, and there is no longer any way to ship a service against a
|
||||
`shared/` it was never built against. The cost is the obvious one: a fix in
|
||||
`vantage-shared` is live nowhere until each consumer's pin is bumped, and
|
||||
nothing in this repository will remind you.
|
||||
|
||||
Every Go build now needs a credential for it — `GOPRIVATE=gitea.hostxtra.co.uk/*`
|
||||
plus a netrc. CI writes one per job from `REGISTRY_USER` + `RELEASE_TOKEN`
|
||||
(**that token needs read access to the `vantage` org, not only `mrhid6`**), and
|
||||
the four Go Dockerfiles take it as a **BuildKit secret** rather than a build
|
||||
arg, because an arg survives in the builder layer's history and this one is a
|
||||
Gitea token. Locally, either a netrc or
|
||||
`git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`.
|
||||
|
||||
**Three build contexts shrank as a result.** `admin`, `sitesvc` and `vantagectl`
|
||||
build from their own directory now; only `server` still builds from the
|
||||
repository root, and only because its runtime stage copies `default_steps/`.
|
||||
One side effect worth knowing: `admin/.dockerignore` was inert while the context
|
||||
was the root, and is live now.
|
||||
|
||||
---
|
||||
|
||||
## Subsystems
|
||||
@@ -383,9 +413,11 @@ one wire shape, worded per platform in the UI, which is the only layer that
|
||||
knows the host's OS. The platform split lives entirely in the agent, as build
|
||||
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
|
||||
and `logs_` pairs); the control plane is OS-blind and needed no changes.
|
||||
Windows collection runs PowerShell through `agent/internal/winexec`, and every
|
||||
script emits JSON that a build-tag-free parser reads, so the parsers are tested
|
||||
on Linux — the agent module has no Windows CI.
|
||||
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
|
||||
@@ -449,15 +481,231 @@ is in `ScopedCollections` (which `scopedCollectionsForPurge` derives from). Ther
|
||||
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.
|
||||
`shared/grpc/pb` is hand-written JSON-tagged structs over the custom codec in
|
||||
`shared/grpc/codec`, and it is **one** package shared by both sides — a message
|
||||
added to it must be added to the `.proto` in the same commit, but there is no
|
||||
longer a second Go copy to keep in step. There used to be two
|
||||
(`agent/internal/grpc/pb` and `server/internal/grpc/pb`) and they had already
|
||||
drifted: the agent's `UnimplementedVantageServer` was three methods stale and
|
||||
carried no `ReportWorkloads` at all. The agent links the server half as dead
|
||||
code, which the linker drops.
|
||||
|
||||
This makes `agent` the **fifth** consumer of `shared/`, and the wire contract now
|
||||
lives outside this repository entirely: a message added to `vantage-shared` is
|
||||
not a message either side has until its pin is bumped. What that buys is that
|
||||
the mismatch is a compile error rather than two copies that both compiled and
|
||||
disagreed on the wire. What it costs is ordering — a wire change needs a
|
||||
`vantage-shared` release, then a pin bump in `server/` (live at the next push to
|
||||
main) and a pin bump in `agent/` (live at the next `agent/v*` tag). The
|
||||
server-ahead-of-fleet gap existed before too; it is now explicit in two `go.mod`
|
||||
files instead of implicit in a shared directory.
|
||||
|
||||
### 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.
|
||||
|
||||
**An incident may only name components the page already carries.**
|
||||
`services.checkAffectedOnPages` refuses an `affected_monitors` entry that no
|
||||
page in the incident's `page_ids` lists, and the editor offers only the saved
|
||||
page's components — labelled by their per-page display name, since that is the
|
||||
name the reader sees. Naming an arbitrary monitor would publish a machine the
|
||||
page deliberately does not, which is the same leak `assembleSnapshot`'s
|
||||
redaction boundary exists to prevent, reached from the authoring side instead
|
||||
of the read side. It is a separate pass rather than part of `validateIncident`
|
||||
because it reads the database and `validateIncident` is a pure function of the
|
||||
document. A component dropped from the page **after** an incident named it
|
||||
makes the next edit of that incident fail, deliberately: the editor renders the
|
||||
stale entry flagged and checked so it is one click from being dropped, and the
|
||||
alternative is a page quietly publishing a component it no longer has.
|
||||
|
||||
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.
|
||||
|
||||
### Backup and restore
|
||||
|
||||
`vantagectl` is a standalone Go module (`vantagectl/`), not a subcommand of
|
||||
`server`. It needs its own module rather than living inside `server`'s for the
|
||||
same reason `admin` and `sitesvc` already do: `server` imports the rest of
|
||||
`server`'s dependency graph, and `spf13/cobra` has no business in a process
|
||||
that also terminates gRPC streams and serves the REST API. More to the point,
|
||||
`vantagectl` has to run when the control plane **does not** — a backup or
|
||||
restore against a database with no server container alive at all — so it
|
||||
cannot be a mode of the binary whose crash is the reason you need it.
|
||||
|
||||
The actual logic lives in `shared/backup` (dump, restore, verify, manifest,
|
||||
fingerprint), not in `vantagectl/internal/cmd`, which holds only argument
|
||||
parsing and operator-facing output. That split is what lets `server` import
|
||||
`shared/backup` later — a scheduled in-process backup, say — without a second
|
||||
implementation to keep in sync. `shared/cryptobox` is the same move one layer
|
||||
down: it is now the **single** AES-256-GCM implementation, and
|
||||
`server/internal/services/crypto.go` delegates to it rather than keeping its
|
||||
own copy that `shared/backup` would otherwise have had to duplicate to decrypt
|
||||
a probe value during `verify`.
|
||||
|
||||
**The archive stores a SHA-256 fingerprint of `KEY_ENCRYPTION_KEY`, never the
|
||||
key.** `backup` refuses to run without the key set in the environment unless
|
||||
`--allow-no-key` is passed, because an archive with no fingerprint at all
|
||||
cannot later tell a restore that the wrong key is in hand — it can only find
|
||||
that out when the data comes back as noise. The fingerprint is what turns that
|
||||
failure into a refusal at `restore` time instead.
|
||||
|
||||
**Collections are enumerated live** — `shared/backup` lists what the database
|
||||
actually holds rather than reading `services.ScopedCollections`, the opposite
|
||||
choice from the one instance-deletion purge makes. Purge must never miss a
|
||||
tenant-scoped collection, so it keeps one hand-maintained registry; a backup
|
||||
must never miss **any** collection, tenant-scoped or not (`migrations`,
|
||||
`vulndb_meta`), so a static list is the wrong shape twice over — once for the
|
||||
collections it would still owe `instance_id` deletion but not a backup, and
|
||||
once for the two singleton collections that carry neither `instance_id` nor a
|
||||
release note.
|
||||
|
||||
**Restore refuses a non-empty target database and has no merge semantics.**
|
||||
There is no code path that upserts an archive's documents over existing ones:
|
||||
merging two control planes' data reconciles nothing about which SSH keys are
|
||||
still valid or which users still exist, and an upsert would resurrect a
|
||||
revoked key or a deleted member from the older side. `--force` drops each
|
||||
collection in the archive first, and is gated behind a second assurance:
|
||||
`--confirm-db NAME` matching the target exactly, which works everywhere, or —
|
||||
on a terminal only, and only when `--confirm-db` was not given — the target
|
||||
database's name typed back at a prompt. `--confirm-db` is accepted on a
|
||||
terminal too: it is the stronger of the two, because naming the target in the
|
||||
command itself means a copied command carries its intended target with it and
|
||||
cannot destroy a different one by accident. Without a terminal and without
|
||||
`--confirm-db`, `--force` is refused.
|
||||
|
||||
**`--force` drops only what the archive names.** Collections already in the
|
||||
target that the archive does not carry are left untouched and **named in a
|
||||
warning** — an archive taken with `--exclude workflow_log_lines` restored over
|
||||
a live database leaves the old lines joined to restored runs, which the
|
||||
operator must be told. Dropping them instead would delete data nobody asked to
|
||||
delete, and there is no way back from that.
|
||||
|
||||
**Index specifications are replayed verbatim, never reconstructed.**
|
||||
`dumpIndexes` stores each spec as extended JSON over the raw BSON the server
|
||||
reported, and `replayIndexes` hands it back to `createIndexes` through
|
||||
`RunCommand` with only `v` and `ns` stripped and `_id_` skipped. Rebuilding a
|
||||
`mongo.IndexModel` from a hand-picked set of options dropped
|
||||
`partialFilterExpression` — which this codebase relies on in
|
||||
`services/workflows.go` and `services/settings.go` — so a partial unique index
|
||||
came back as a full one, failed on duplicate keys, and aborted the restore
|
||||
mid-write. Reconstructing the key document from JSON also lost compound key
|
||||
order, which is significant.
|
||||
|
||||
**`backup.ciphertextFields` mirrors `server/internal/models` by hand.**
|
||||
`shared/` is a separate module — a separate *repository* now — and `models` is
|
||||
under `server/internal`, so `shared/backup` cannot import it; the map naming each collection's `*_enc`
|
||||
fields (`keys`, `secrets`, `auth_providers`, `console_sessions`) must change in
|
||||
the same commit as any of those bson tags, the same hazard as
|
||||
`web/lib/targets.ts` and `services.MaxWorkloadLogLines`. Wrong field names are
|
||||
silent: `verify`'s live probe simply finds no ciphertext and reports "this
|
||||
database stores no ciphertext yet", so the one gate that catches what a
|
||||
fingerprint cannot no-ops. `settings` is deliberately in neither that map nor
|
||||
`CiphertextCollections()` — its ESO read token is a SHA-256 hash, not
|
||||
ciphertext.
|
||||
|
||||
**A file-backed `backup` writes to `<name>.tar.gz.partial` and renames on
|
||||
success**, the same discipline the agent uses for `authorized_keys`. A failed
|
||||
dump must not leave a partial file named exactly like a good archive; `--out -`
|
||||
is untouched, since a broken pipe has no file to mislead anyone.
|
||||
|
||||
**`vantagectl/Dockerfile`'s runtime stage is `scratch`, and needs the same
|
||||
explicit `/tmp` as `server/Dockerfile`.** `restore` extracts an archive to a
|
||||
temporary directory before verifying its checksums, and a scratch image has no
|
||||
`/tmp` for `os.MkdirTemp` to find — the same failure mode `vulnsched` hits on
|
||||
`server`, but here it would break every restore rather than only vulnerability
|
||||
scanning.
|
||||
|
||||
**Nothing in this repository rebuilds when `vantage-shared` changes.** It is an
|
||||
external module, so a `shared/backup` fix reaches `vantagectl` when somebody
|
||||
bumps `vantagectl/go.mod` and cuts a `vantagectl/v*` tag, and reaches nothing
|
||||
else until its pin moves too — see the CI section below.
|
||||
|
||||
### API tokens and OpenAPI
|
||||
|
||||
A token is `vt_` plus 32 random bytes hex, shown once at creation and stored
|
||||
@@ -465,9 +713,9 @@ only as sha256 — the same shape as `servers.agent_token_hash` and the ESO read
|
||||
token, and for the same reason: nothing downstream ever needs the plaintext
|
||||
back. It belongs to the user who created it, and its role can never exceed
|
||||
theirs; see the `api_tokens` note under MongoDB Collections for how that stays
|
||||
true across a demotion rather than only at issuance. Scopes are eight
|
||||
true across a demotion rather than only at issuance. Scopes are nine
|
||||
resources — `servers`, `keys`, `secrets`, `workflows`, `monitors`, `vulns`,
|
||||
`workloads`, `settings` — each split into `:read` and `:write`, with `:write`
|
||||
`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
|
||||
@@ -746,6 +994,10 @@ 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)
|
||||
@@ -829,7 +1081,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `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/`.
|
||||
|
||||
@@ -852,7 +1104,9 @@ Notes that are not obvious from the structs:
|
||||
|
||||
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.
|
||||
`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. A row carries a `scope`: `plan` rows name a `deployment` and `tier` and belong to that plan alone, `shared` rows leave both empty and are sold by every paid plan. **How many rows a component needs follows from how many Paddle products it is** — the base fee is a different product per plan, every add-on is one product at one price, so the catalogue is four base rows plus five shared rows, nine instead of twenty-four, and an add-on's price ID is typed once rather than four times. `models.CatalogueFor` is the seam: it returns a plan's base row plus every shared row, and **nothing may filter the catalogue by `deployment` and `tier` itself** or it sees a plan priced by its base fee alone. `adminsite/lib/catalogue.ts`'s `rowsForPlan` is the TypeScript half of that and must change in the same commit, the same shape of hazard as `web/lib/targets.ts`. `models.MigrateSharedCatalogue` runs at boot after `SeedCatalogue`, merges the old per-plan copies onto the shared row and deletes them; it **refuses rather than guesses** when the four copies disagree, because four rows meant to be one price and are not is a pricing decision somebody made and picking one silently moves a customer's bill. `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 shared `KindFeature` row in `SeedCatalogue`'s `seedRows` 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 the row's natural key `(kind, deployment, tier, limit_key, feature_key)` — a shared row's empty deployment and tier are part of that key, not a wildcard — 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
|
||||
|
||||
@@ -895,7 +1149,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
|
||||
```
|
||||
@@ -938,6 +1192,7 @@ 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 |
|
||||
@@ -969,7 +1224,7 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
|
||||
|
||||
**`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` and `/auth` straight to the server.** Both arrangements work — without it `web` proxies those prefixes onward itself (`web/next.config.ts`) — but edge routing is one hop shorter and matches what the Nginx Proxy Manager in front of the Docker deployment already does, so leaving it off makes the request path a different shape on Kubernetes than in production. It stays **off by default** because it only helps where the server is reachable on the same host and certificate as `web`; turning it on blindly moves the whole API onto a route that may not be provisioned. Traefik derives router priority from rule length, so `PathPrefix(/api)` outranks the catch-all `/` with no priority annotation needed.
|
||||
**`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.
|
||||
|
||||
@@ -979,6 +1234,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.
|
||||
@@ -1028,6 +1285,27 @@ Tailwind in all three maps `var(--…)` references only, so **no component in an
|
||||
|
||||
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
|
||||
|
||||
**Plans and the catalogue are one page, `/staff/pricing`.** They were two nav
|
||||
entries and the split asked staff to hold one half in their head while looking
|
||||
at the other: a tier's allowance is what the metered component charges above,
|
||||
and a base fee means nothing without the allowance it includes. The page is
|
||||
`PlansSection` then `CatalogueSection`, in the order the decision is made —
|
||||
what a tier grants, then what it costs. `next.config.ts` keeps permanent
|
||||
redirects from `/staff/plans` and `/staff/catalogue`, which are bookmarked in
|
||||
staff browsers. **The tier list is cards, not forms**: six plans with five
|
||||
number fields, a select, a checkbox and four feature toggles each was forty-odd
|
||||
controls on one screen, and the page could not be read for the thing it exists
|
||||
to answer. A card states what the tier grants and `Modal` — a native
|
||||
`<dialog>`, for the focus trap and Escape handling a hand-rolled overlay gets
|
||||
wrong — is where it is changed. Every feature key renders on every card, lit or
|
||||
unlit: no tier bundles one today, so the unlit row is the information.
|
||||
|
||||
**The catalogue's coverage ledger is not decoration.** A missing production
|
||||
price is invisible in a grid of text inputs — every cell looks like every other
|
||||
until twenty-six characters of each are read — and it is the one thing staff
|
||||
come to the page to check before a launch, so each component draws one filled
|
||||
or empty square per environment and term.
|
||||
|
||||
**The `adminsite/` shell.** `AppBar` is the single masthead — identity, nav, environment, account menu — and it belongs to the two authenticated layouts, never to `app/layout.tsx`, so `/login` and `/accept-invite` do not render navigation they cannot use. Nav active state is derived from `usePathname`; do not hardcode it. `PageHeader` gives every screen the same back link, title, actions and **record line** (the reference number in mono, click-to-copy) — the reference is what people paste into support tickets, so it has a fixed slot rather than a per-page treatment. `PageFrame` is the main-plus-320px-rail split; the rail carries only what is true account-wide, which is why there is no plan card in it — **tier, limits and expiry belong to a licence, and a licence belongs to one instance**, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan.
|
||||
|
||||
Customer nav is three destinations — Overview, People, Billing. Settings is in the account menu because it is your password, not a place, and appearance lives there too: `AccountMenu` is the only thing that sets `data-theme`, which the token blocks have always supported in both directions.
|
||||
@@ -1086,7 +1364,7 @@ GOOS=linux GOARCH=amd64 go build \
|
||||
|
||||
### `server-deploy.yml` — triggered on every push to `main`
|
||||
|
||||
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
|
||||
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`. **`vantagectl` is deliberately not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`.
|
||||
|
||||
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
|
||||
|
||||
@@ -1097,14 +1375,33 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
|
||||
|
||||
**Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly:
|
||||
|
||||
| Image | Rebuilds when |
|
||||
| ---------------------------- | ----------------------------------------- |
|
||||
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
|
||||
| `admin` | `admin/`, `shared/`, `go.work` |
|
||||
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
|
||||
| `web` · `site` · `adminsite` · `docsite` | their own directory only |
|
||||
| Image | Rebuilds when |
|
||||
| ---------------------------- | -------------------------------- |
|
||||
| `server` | `server/`, `proto/`, `go.work` |
|
||||
| `admin` | `admin/`, `go.work` |
|
||||
| `sitesvc` | `sitesvc/`, `go.work` |
|
||||
| `web` · `site` · `adminsite` · `docsite` | their own directory only |
|
||||
|
||||
`shared/` fans out to all three Go images because each of their Dockerfiles copies `shared/` from a root context — **if a fourth service ever imports `shared/`, add it to that list or it will ship stale**. A change to the workflow file rebuilds everything, since a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone.
|
||||
**No path in this table names `shared/` any more**, and no fan-out rule replaces
|
||||
it: `vantage-shared` is an external module pinned per service, so a service
|
||||
rebuilds when its own `go.mod` moves, which its own directory pattern already
|
||||
matches. What that removes is the failure where a `shared/` edit rebuilt three
|
||||
images and one of them was not ready; what it adds is that nothing here reminds
|
||||
you a pin is stale.
|
||||
|
||||
Every Go build in these workflows writes a netrc from `REGISTRY_USER` +
|
||||
`RELEASE_TOKEN` before it runs, and sets `GOPRIVATE=gitea.hostxtra.co.uk/*`.
|
||||
There are **five** such places, and each needs its own because jobs do not share
|
||||
a filesystem: `server-deploy.yml`'s single job, both jobs of
|
||||
`agent-release.yml` (the `msi` job is Windows, where Go reads `%USERPROFILE%\_netrc`,
|
||||
not `.netrc`) and both jobs of `vantagectl-release.yml`. The docker builds pass
|
||||
it on as `--secret id=netrc`, never a build arg. **`RELEASE_TOKEN` needs read
|
||||
access to the `vantage` org** on top of its existing scopes; without it every Go
|
||||
build fails at `go mod download` with a 404 on the module, which reads like a
|
||||
missing tag rather than a missing permission. A change to the workflow file rebuilds everything, since
|
||||
a build arg is baked into the image. So does anything that leaves no
|
||||
trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a
|
||||
force-push whose old head is gone.
|
||||
|
||||
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
|
||||
|
||||
@@ -1133,11 +1430,11 @@ git push origin main # server + web deploy
|
||||
|
||||
| Name | Type | Value |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart). **This is the only token any workflow authenticates with** — `docker login` and the chart publish both pair it with `REGISTRY_USER` |
|
||||
| `RELEASE_TOKEN` | Secret | Gitea API token. Needs `write:release` (agent releases), `write:package` (container images and the Helm chart) and **read access to the `vantage` org**, which is where the private `vantage-shared` module lives — without that last one every Go build fails at `go mod download` with what looks like a missing tag. **This is the only token any workflow authenticates with** — `docker login`, the chart publish and the module netrc all pair it with `REGISTRY_USER` |
|
||||
| `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. |
|
||||
@@ -1171,9 +1468,11 @@ git push origin main # server + web deploy
|
||||
- **Windows agents cover the fleet-management path** — register, heartbeat, run
|
||||
steps, report inventory, OS updates through the Windows Update COM API, and
|
||||
workloads (services plus containers, with control and logs). They still do no
|
||||
`authorized_keys` management, and no package inventory or CVE matching: the
|
||||
vulnerability feeds this project uses carry no Windows data, so a Windows host
|
||||
correctly reports `unsupported` rather than a clean bill of health.
|
||||
`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.
|
||||
|
||||
|
||||
+16
-8
@@ -1,17 +1,25 @@
|
||||
# Context is the repository root; admin depends on the shared module.
|
||||
# Context is admin/ itself. It used to be the repository root, so that
|
||||
# shared/ could be copied in beside it; shared is now the private module
|
||||
# gitea.hostxtra.co.uk/vantage/vantage-shared, fetched like any other
|
||||
# dependency. The credential for it arrives as a BuildKit secret rather than a
|
||||
# build arg, which would be baked into this stage's layer history.
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY admin/go.mod admin/go.sum ./admin/
|
||||
RUN cd admin && go mod download
|
||||
ENV GOPRIVATE=gitea.hostxtra.co.uk/*
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY shared/ ./shared/
|
||||
COPY admin/ ./admin/
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
go mod download
|
||||
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
|
||||
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
|
||||
COPY . .
|
||||
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
|
||||
|
||||
FROM alpine:3.20 AS runner
|
||||
|
||||
|
||||
+5
-1
@@ -22,7 +22,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
|
||||
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
|
||||
sharedmail "gitea.hostxtra.co.uk/vantage/vantage-shared/mail"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
@@ -86,6 +86,10 @@ func main() {
|
||||
idxCancel()
|
||||
log.Fatalf("seed catalogue: %v", err)
|
||||
}
|
||||
if err := models.MigrateSharedCatalogue(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("migrate catalogue: %v", err)
|
||||
}
|
||||
if err := models.Backfill(idxCtx); err != nil {
|
||||
idxCancel()
|
||||
log.Fatalf("backfill: %v", err)
|
||||
|
||||
+1
-3
@@ -3,10 +3,10 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/admin
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
@@ -51,5 +51,3 @@ require (
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"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/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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"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/models"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -485,6 +485,7 @@ func staffListCatalogue(c *gin.Context) {
|
||||
func staffUpdateCatalogue(c *gin.Context) {
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
Deployment string `json:"deployment"`
|
||||
Tier string `json:"tier"`
|
||||
LimitKey string `json:"limit_key"`
|
||||
@@ -500,11 +501,19 @@ func staffUpdateCatalogue(c *gin.Context) {
|
||||
// mean a resolved self-hosted monthly price later, which the resolver treats
|
||||
// as a configuration error — better to refuse it at the point somebody
|
||||
// pastes it, while they are looking at the screen.
|
||||
//
|
||||
// A shared row is sold by both deployments, so both terms are legitimate on
|
||||
// it: the cloud checkout takes the monthly price and the self-hosted one
|
||||
// never asks for it. Only a plan row can name a term its own deployment
|
||||
// does not sell.
|
||||
for env, byTerm := range body.PriceIDs {
|
||||
for term, id := range byTerm {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if body.Scope == models.ScopeShared {
|
||||
continue
|
||||
}
|
||||
if !termSold(body.Deployment, term) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("%s does not sell %s (environment %s)",
|
||||
@@ -514,6 +523,8 @@ func staffUpdateCatalogue(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Addressed by its natural key, so the staff UI never holds a Mongo id. A
|
||||
// shared row's empty deployment and tier are part of that key.
|
||||
filter := bson.M{
|
||||
"kind": body.Kind,
|
||||
"deployment": body.Deployment,
|
||||
@@ -534,12 +545,21 @@ func staffUpdateCatalogue(c *gin.Context) {
|
||||
audit.Write(c.Request.Context(), models.AuditEntry{
|
||||
Actor: auth.Current(c).Email,
|
||||
Action: "catalogue.updated",
|
||||
Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
|
||||
Target: catalogueTarget(body.Scope, body.Deployment, body.Tier, body.Kind),
|
||||
Detail: body.LimitKey + body.FeatureKey,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
// catalogueTarget names an edited component in the audit log. A shared row has
|
||||
// no plan to name, so it says so rather than logging "//feature".
|
||||
func catalogueTarget(scope, deployment, tier, kind string) string {
|
||||
if scope == models.ScopeShared {
|
||||
return "shared/" + kind
|
||||
}
|
||||
return deployment + "/" + tier + "/" + kind
|
||||
}
|
||||
|
||||
func termSold(deployment, term string) bool {
|
||||
for _, t := range license.TermsFor(deployment) {
|
||||
if t == term {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
sharedmail "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
|
||||
sharedmail "gitea.hostxtra.co.uk/vantage/vantage-shared/mail"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"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"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
)
|
||||
|
||||
// deliver sends a freshly issued licence where it belongs. Cloud is injected;
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
)
|
||||
|
||||
// Item is one Paddle line item: a price and how many of it.
|
||||
@@ -29,8 +29,18 @@ func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg mod
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
|
||||
// A plan is identified by its base row, and shared add-on rows exist whether
|
||||
// or not any plan sells them — so "the catalogue returned something" is no
|
||||
// longer proof this plan is priced. Check for the base row itself.
|
||||
hasBase := false
|
||||
for _, r := range rows {
|
||||
if r.Kind == models.KindBase {
|
||||
hasBase = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasBase {
|
||||
return nil, fmt.Errorf("%w: %s/%s has no base row",
|
||||
ErrUnpriced, plan.Deployment, plan.Tier)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/provision"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"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/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"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/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// api, auth, billing and lifecycle.
|
||||
package mail
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/shared/mail"
|
||||
import "gitea.hostxtra.co.uk/vantage/vantage-shared/mail"
|
||||
|
||||
// Default is admin's sender. Set once by main; read everywhere else.
|
||||
var Default mail.Sender
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/vantage/vantage-shared/models"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
@@ -2,9 +2,11 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
@@ -19,6 +21,23 @@ const (
|
||||
KindFeature = "feature"
|
||||
)
|
||||
|
||||
// Component scopes.
|
||||
//
|
||||
// A component is priced by one Paddle product, and how many catalogue rows it
|
||||
// needs follows from how many products it is. The base fee is a different
|
||||
// product per plan, so it is a row per plan. Every add-on — the server limit and
|
||||
// all four features — is ONE product sold to every paid plan at one price, so it
|
||||
// is one row, and its price ID is typed once instead of four times.
|
||||
//
|
||||
// Scope is stored rather than inferred from Kind so the rule is data. Pricing a
|
||||
// future add-on per tier is then a scope on a row, not a rewrite of every reader.
|
||||
const (
|
||||
// ScopePlan rows carry a deployment and a tier and belong to that plan alone.
|
||||
ScopePlan = "plan"
|
||||
// ScopeShared rows leave deployment and tier empty and belong to every paid plan.
|
||||
ScopeShared = "shared"
|
||||
)
|
||||
|
||||
// LimitKeyServers is the only metered limit today.
|
||||
//
|
||||
// A limit_key is a field name in license.Limits, which is what lets a second
|
||||
@@ -27,19 +46,23 @@ const (
|
||||
// would be 1 in every row that will ever exist.
|
||||
const LimitKeyServers = "max_servers"
|
||||
|
||||
// CatalogueRow is one priceable component of one plan.
|
||||
// CatalogueRow is one priceable component.
|
||||
//
|
||||
// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty
|
||||
// PriceIDs means the component is free — a feature with no price is a toggle a
|
||||
// customer may take at no charge, and giving it a price later is a staff edit
|
||||
// rather than a migration or a deploy.
|
||||
type CatalogueRow struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Kind string `bson:"kind" json:"kind"`
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
|
||||
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
Kind string `bson:"kind" json:"kind"`
|
||||
Scope string `bson:"scope" json:"scope"`
|
||||
// Deployment and Tier are empty on a shared row, and are what a plan row is
|
||||
// keyed by. Readers must go through CatalogueFor rather than filtering on
|
||||
// them, or a shared row is invisible to the plan that sells it.
|
||||
Deployment string `bson:"deployment" json:"deployment"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
|
||||
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
|
||||
// PriceIDs is environment -> term -> Paddle price ID, e.g.
|
||||
// {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}.
|
||||
//
|
||||
@@ -67,57 +90,174 @@ func (r CatalogueRow) Priced(env string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key.
|
||||
// Shared reports whether this row is sold by every paid plan.
|
||||
func (r CatalogueRow) Shared() bool { return r.Scope == ScopeShared }
|
||||
|
||||
// naturalKey is how a row is addressed everywhere: by what it is, never by its
|
||||
// ObjectID. A shared row's deployment and tier are empty, and that emptiness is
|
||||
// part of the key rather than a wildcard.
|
||||
func (r CatalogueRow) naturalKey() bson.M {
|
||||
return bson.M{
|
||||
"kind": r.Kind,
|
||||
"deployment": r.Deployment,
|
||||
"tier": r.Tier,
|
||||
"limit_key": r.LimitKey,
|
||||
"feature_key": r.FeatureKey,
|
||||
}
|
||||
}
|
||||
|
||||
// seedRows is the catalogue as it should exist: four base rows, one per paid
|
||||
// plan, plus five shared add-on rows every paid plan sells.
|
||||
//
|
||||
// Nine rows, down from twenty-four. The count 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
|
||||
// "fix" this by adding zero-priced Free rows.
|
||||
func seedRows() []CatalogueRow {
|
||||
rows := []CatalogueRow{}
|
||||
paid := []string{license.TierProfessional, license.TierEnterprise}
|
||||
for _, deployment := range license.Deployments() {
|
||||
for _, tier := range paid {
|
||||
rows = append(rows, CatalogueRow{
|
||||
Kind: KindBase, Scope: ScopePlan, Deployment: deployment, Tier: tier,
|
||||
})
|
||||
}
|
||||
}
|
||||
rows = append(rows, CatalogueRow{
|
||||
Kind: KindLimit, Scope: ScopeShared, LimitKey: LimitKeyServers,
|
||||
})
|
||||
for _, f := range []string{
|
||||
license.FeatureConsole,
|
||||
license.FeatureOIDC,
|
||||
license.FeatureVulnScanning,
|
||||
license.FeatureStatusPages,
|
||||
} {
|
||||
rows = append(rows, CatalogueRow{
|
||||
Kind: KindFeature, Scope: ScopeShared, FeatureKey: f,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the nine rows the four paid plans need.
|
||||
//
|
||||
// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted
|
||||
// in by staff and a redeploy must not blank them.
|
||||
func SeedCatalogue(ctx context.Context) error {
|
||||
paid := []string{license.TierProfessional, license.TierEnterprise}
|
||||
for _, deployment := range license.Deployments() {
|
||||
for _, tier := range paid {
|
||||
rows := []CatalogueRow{
|
||||
{Kind: KindBase, Deployment: deployment, Tier: tier},
|
||||
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
|
||||
}
|
||||
for _, r := range rows {
|
||||
filter := bson.M{
|
||||
"kind": r.Kind,
|
||||
"deployment": r.Deployment,
|
||||
"tier": r.Tier,
|
||||
"limit_key": r.LimitKey,
|
||||
"feature_key": r.FeatureKey,
|
||||
}
|
||||
if _, err := db.Admin("catalogue").UpdateOne(ctx, filter,
|
||||
bson.M{"$setOnInsert": bson.M{
|
||||
"kind": r.Kind,
|
||||
"deployment": r.Deployment,
|
||||
"tier": r.Tier,
|
||||
"limit_key": r.LimitKey,
|
||||
"feature_key": r.FeatureKey,
|
||||
"price_ids": map[string]map[string]string{},
|
||||
}},
|
||||
options.UpdateOne().SetUpsert(true)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, r := range seedRows() {
|
||||
set := r.naturalKey()
|
||||
set["scope"] = r.Scope
|
||||
set["price_ids"] = map[string]map[string]string{}
|
||||
if _, err := db.Admin("catalogue").UpdateOne(ctx, r.naturalKey(),
|
||||
bson.M{"$setOnInsert": set},
|
||||
options.UpdateOne().SetUpsert(true)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CatalogueFor returns every component of one plan.
|
||||
// MigrateSharedCatalogue collapses the four per-plan copies of each add-on onto
|
||||
// the one shared row, and deletes the copies.
|
||||
//
|
||||
// It runs after SeedCatalogue, which has already created the shared rows empty,
|
||||
// and is idempotent: once the per-plan copies are gone there is nothing to move.
|
||||
//
|
||||
// It REFUSES rather than guesses when the copies disagree. Four rows that were
|
||||
// meant to be one price and are not is a real pricing decision somebody made,
|
||||
// and picking one of them silently would move a customer's bill.
|
||||
func MigrateSharedCatalogue(ctx context.Context) error {
|
||||
// Rows seeded before scope existed are all per-plan rows. Naming them so
|
||||
// keeps CatalogueFor's $or honest for the base rows that survive.
|
||||
if _, err := db.Admin("catalogue").UpdateMany(ctx,
|
||||
bson.M{"scope": bson.M{"$exists": false}},
|
||||
bson.M{"$set": bson.M{"scope": ScopePlan}}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, shared := range seedRows() {
|
||||
if !shared.Shared() {
|
||||
continue
|
||||
}
|
||||
cur, err := db.Admin("catalogue").Find(ctx, bson.M{
|
||||
"kind": shared.Kind,
|
||||
"limit_key": shared.LimitKey,
|
||||
"feature_key": shared.FeatureKey,
|
||||
"deployment": bson.M{"$ne": ""},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
old := []CatalogueRow{}
|
||||
if err := cur.All(ctx, &old); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(old) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var target CatalogueRow
|
||||
if err := db.Admin("catalogue").FindOne(ctx, shared.naturalKey()).Decode(&target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merged := target.PriceIDs
|
||||
if merged == nil {
|
||||
merged = map[string]map[string]string{}
|
||||
}
|
||||
for _, o := range old {
|
||||
for env, byTerm := range o.PriceIDs {
|
||||
for term, id := range byTerm {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if merged[env] == nil {
|
||||
merged[env] = map[string]string{}
|
||||
}
|
||||
if have := merged[env][term]; have != "" && have != id {
|
||||
return fmt.Errorf(
|
||||
"catalogue: %s%s was priced differently per plan (%s %s: %q and %q); "+
|
||||
"decide which price is the shared one and delete the others before upgrading",
|
||||
shared.LimitKey, shared.FeatureKey, env, term, have, id)
|
||||
}
|
||||
merged[env][term] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Admin("catalogue").UpdateOne(ctx, shared.naturalKey(),
|
||||
bson.M{"$set": bson.M{"price_ids": merged}}); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]bson.ObjectID, 0, len(old))
|
||||
for _, o := range old {
|
||||
ids = append(ids, o.ID)
|
||||
}
|
||||
if _, err := db.Admin("catalogue").DeleteMany(ctx,
|
||||
bson.M{"_id": bson.M{"$in": ids}}); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("catalogue: merged %d per-plan rows into shared %s%s",
|
||||
len(old), shared.LimitKey, shared.FeatureKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CatalogueFor returns every component one plan sells: its own base row plus
|
||||
// every shared add-on.
|
||||
//
|
||||
// This is the seam the whole shared-row change rests on. Every reader that used
|
||||
// to filter the catalogue by deployment and tier must come through here instead,
|
||||
// or it sees a plan priced by nothing but its base fee.
|
||||
func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) {
|
||||
deployment, tier = license.NormaliseTier(deployment, tier)
|
||||
cur, err := db.Admin("catalogue").Find(ctx,
|
||||
bson.M{"deployment": deployment, "tier": tier})
|
||||
cur, err := db.Admin("catalogue").Find(ctx, bson.M{"$or": []bson.M{
|
||||
{"scope": ScopeShared},
|
||||
{"deployment": deployment, "tier": tier},
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { rowsForPlan, sharedRows } from "@/lib/catalogue";
|
||||
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";
|
||||
@@ -60,24 +61,24 @@ export function PurchaseForm() {
|
||||
const options = optionsQ.data;
|
||||
const accountId = account.data?.account.account_id ?? "";
|
||||
|
||||
// Distinct feature keys offered on this deployment, in a stable order.
|
||||
// Every feature a paid plan can be sold, in a stable order. Features are
|
||||
// shared rows now, so they no longer differ by deployment — the list is the
|
||||
// same on both, and reads from one place rather than four.
|
||||
const featureKeys = useMemo(() => {
|
||||
if (!options) return [] as string[];
|
||||
const keys = new Set<string>();
|
||||
for (const r of options.catalogue) {
|
||||
if (r.deployment === dep && r.kind === "feature" && r.feature_key) {
|
||||
keys.add(r.feature_key);
|
||||
}
|
||||
for (const r of sharedRows(options.catalogue)) {
|
||||
if (r.kind === "feature" && r.feature_key) keys.add(r.feature_key);
|
||||
}
|
||||
return [...keys];
|
||||
}, [options, dep]);
|
||||
}, [options]);
|
||||
|
||||
const activePlans = useMemo(() => (options?.plans ?? []).filter((p) => p.deployment === dep && p.active).sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)), [options, dep]);
|
||||
const plan = activePlans.find((p) => p.tier === choice.tier);
|
||||
const baseServers = plan?.base_limits.max_servers ?? 0;
|
||||
const unlimited = baseServers === -1;
|
||||
|
||||
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
|
||||
const rows = useMemo(() => rowsForPlan(options?.catalogue ?? [], dep, choice.tier), [options, dep, choice.tier]);
|
||||
|
||||
// Real line items for the current configuration the same builder the
|
||||
// checkout uses, so the summary can never disagree with the overlay.
|
||||
@@ -239,7 +240,7 @@ export function PurchaseForm() {
|
||||
headline={p.tier === "free" ? "£0" : basePrices[p.tier]}
|
||||
cycleLabel={cycleShort(dep, choice.term)}
|
||||
featureKeys={featureKeys}
|
||||
catalogue={options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier)}
|
||||
catalogue={rowsForPlan(options.catalogue, dep, p.tier)}
|
||||
env={options.env}
|
||||
term={choice.term}
|
||||
onSelect={() =>
|
||||
@@ -252,7 +253,7 @@ export function PurchaseForm() {
|
||||
features: c.features.filter((k) => {
|
||||
const st = featureStateFor(
|
||||
p,
|
||||
options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier),
|
||||
rowsForPlan(options.catalogue, dep, p.tier),
|
||||
options.env,
|
||||
c.term,
|
||||
k,
|
||||
@@ -658,7 +659,7 @@ function Receipt({
|
||||
// Label each real line item from the catalogue, and price it from Paddle.
|
||||
const base = plan?.base_limits.max_servers ?? 0;
|
||||
const extra = base === -1 ? 0 : Math.max(0, choice.servers - base);
|
||||
const rows = options.catalogue.filter((r) => r.deployment === dep && r.tier === choice.tier);
|
||||
const rows = rowsForPlan(options.catalogue, dep, choice.tier);
|
||||
const idFor = (predicate: (r: CatalogueRow) => boolean) => {
|
||||
const row = rows.find(predicate);
|
||||
return row?.price_ids?.[options.env]?.[choice.term] ?? "";
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
|
||||
/* Self-hosted sells annual only, so the monthly cell is not rendered for it
|
||||
* rather than rendered and rejected. The backend refuses one either way; this is
|
||||
* so nobody types into a field that cannot be saved. */
|
||||
function termsFor(deployment: string): Term[] {
|
||||
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
|
||||
}
|
||||
|
||||
function componentLabel(r: CatalogueRow): string {
|
||||
if (r.kind === "base") return "Base fee";
|
||||
if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
|
||||
return `Feature: ${r.feature_key}`;
|
||||
}
|
||||
|
||||
function rowKey(r: CatalogueRow): string {
|
||||
return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
|
||||
}
|
||||
|
||||
export default function CataloguePage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: rows = [], isLoading } = useQuery({
|
||||
queryKey: ["staff", "catalogue"],
|
||||
queryFn: api.staff.catalogue,
|
||||
});
|
||||
const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
|
||||
});
|
||||
|
||||
const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Catalogue"
|
||||
back={{ href: "/staff", label: "Operations" }}
|
||||
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
|
||||
/>
|
||||
<PageFrame
|
||||
aside={
|
||||
<aside className="space-y-3 text-[0.82rem] text-ink-2">
|
||||
<p>
|
||||
A component with no price ID is free. A feature with no price is a
|
||||
toggle a customer may take at no charge; giving it a price here is
|
||||
all it takes to start charging for it.
|
||||
</p>
|
||||
<p>
|
||||
Free is priced by nothing and has no rows. That absence is what
|
||||
keeps it outside Paddle.
|
||||
</p>
|
||||
<p>
|
||||
Changing a price affects the next checkout only. It cannot touch an
|
||||
issued licence.
|
||||
</p>
|
||||
</aside>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading…</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{groups.map((g) => {
|
||||
const [deployment, tier] = g.split("/");
|
||||
const terms = termsFor(deployment);
|
||||
return (
|
||||
<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}`}>
|
||||
{env} / {t}
|
||||
</TH>
|
||||
)),
|
||||
)}
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows
|
||||
.filter(
|
||||
(r) =>
|
||||
r.deployment === deployment &&
|
||||
r.tier === tier,
|
||||
)
|
||||
.map((r) => {
|
||||
const k = rowKey(r);
|
||||
const ids = drafts[k] ?? r.price_ids ?? {};
|
||||
const dirty =
|
||||
JSON.stringify(ids) !==
|
||||
JSON.stringify(r.price_ids ?? {});
|
||||
return (
|
||||
<TR key={k}>
|
||||
<TD className="text-ink">{componentLabel(r)}</TD>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<TD key={`${env}-${t}`}>
|
||||
<input
|
||||
value={
|
||||
ids[env]?.[t] ?? ""
|
||||
}
|
||||
placeholder="pri_…"
|
||||
onChange={(e) =>
|
||||
setDrafts({
|
||||
...drafts,
|
||||
[k]: {
|
||||
...ids,
|
||||
[env]: {
|
||||
...(ids[
|
||||
env
|
||||
] ?? {}),
|
||||
[t]: e
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
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 numeric>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!dirty || save.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
...r,
|
||||
price_ids: ids,
|
||||
})
|
||||
}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,7 @@ const LINKS: NavLink[] = [
|
||||
{ href: "/staff", label: "Operations" },
|
||||
{ href: "/staff/accounts", label: "Accounts" },
|
||||
{ href: "/staff/licenses", label: "Licences" },
|
||||
{ href: "/staff/plans", label: "Plans" },
|
||||
{ href: "/staff/catalogue", label: "Catalogue" },
|
||||
{ href: "/staff/pricing", label: "Pricing" },
|
||||
{ href: "/staff/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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" },
|
||||
{ value: "email_24_5", label: "Email, 24/5" },
|
||||
{ value: "email_call_24_7", label: "Email + call, 24/7" },
|
||||
] as const;
|
||||
|
||||
const LIMIT_FIELDS = [
|
||||
{ key: "max_servers", label: "Servers" },
|
||||
{ key: "max_monitors", label: "Monitors" },
|
||||
{ key: "max_secret_groups", label: "Secret groups" },
|
||||
{ key: "max_channels", label: "Channels" },
|
||||
{ key: "audit_retention_days", label: "Audit history (days)" },
|
||||
] as const;
|
||||
|
||||
/*
|
||||
* -1 is Unlimited everywhere in the licence payload, so the form takes it
|
||||
* literally rather than inventing a checkbox. A staff screen that hides the
|
||||
* sentinel is a staff screen where nobody can tell whether a plan says
|
||||
* unlimited or nothing at all.
|
||||
*/
|
||||
function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) {
|
||||
const [draft, setDraft] = useState<Plan>(plan);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
base_limits: {
|
||||
...draft.base_limits,
|
||||
[f.key]: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
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>
|
||||
))}
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">Support level</span>
|
||||
<select
|
||||
value={draft.support_level}
|
||||
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
|
||||
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}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
|
||||
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
|
||||
Offered to customers
|
||||
</label>
|
||||
|
||||
<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="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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlansPage() {
|
||||
const qc = useQueryClient();
|
||||
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
|
||||
const licenses = useQuery({
|
||||
queryKey: ["staff-licenses"],
|
||||
queryFn: () => api.staff.licenses(),
|
||||
});
|
||||
const [draft, setDraft] = useState<Plan | null>(null);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["plans"] });
|
||||
setDraft(null);
|
||||
setSaving(null);
|
||||
},
|
||||
onError: () => setSaving(null),
|
||||
});
|
||||
|
||||
const original = plans.data?.find((p) => p.deployment === draft?.deployment && p.tier === draft?.tier);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Plans"
|
||||
subtitle="The authoritative tier table six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
/>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={draft}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
|
||||
onConfirm={() => {
|
||||
setSaving(`${draft.deployment}/${draft.tier}`);
|
||||
save.mutate(draft);
|
||||
}}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<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) => (
|
||||
<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)} />
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { SectionHeading } from "./SectionHeading";
|
||||
import { planRows, rowKey, sharedRows } from "@/lib/catalogue";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
import { api, type CatalogueRow, type Term } from "@/lib/api";
|
||||
|
||||
const ENVS = ["sandbox", "production"] as const;
|
||||
|
||||
/* A shared row is sold by both deployments, so it holds both terms: the cloud
|
||||
* checkout takes the monthly price and the self-hosted one never asks for it. A
|
||||
* plan row offers only the terms its own deployment sells — self-hosted is
|
||||
* annual only, and the field is not rendered rather than rendered and refused. */
|
||||
function termsFor(r: CatalogueRow): Term[] {
|
||||
if (r.scope === "shared") return ["monthly", "annual"];
|
||||
return r.deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
|
||||
}
|
||||
|
||||
function componentLabel(r: CatalogueRow): string {
|
||||
if (r.kind === "base") return `${r.tier === "enterprise" ? "Enterprise" : "Professional"} (${r.deployment === "cloud" ? "Cloud" : "Self-hosted"})`;
|
||||
if (r.kind === "limit") return "Additional server";
|
||||
return featureLabel(r.feature_key ?? "");
|
||||
}
|
||||
|
||||
function componentDetail(r: CatalogueRow): string {
|
||||
if (r.kind === "base") return "The plan's own fee, always quantity 1";
|
||||
if (r.kind === "limit") return `Raises ${r.limit_key} by one per unit`;
|
||||
return `feature · ${r.feature_key}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* The coverage ledger: one square per environment and term, filled when that
|
||||
* cell holds a price ID.
|
||||
*
|
||||
* A missing production price is invisible in a grid of text inputs — every cell
|
||||
* looks like every other until you read twenty-six characters of each. This is
|
||||
* the one thing staff come to this page to check before a launch, so it reads
|
||||
* before the IDs do.
|
||||
*/
|
||||
function Coverage({ row, terms }: { row: CatalogueRow; terms: Term[] }) {
|
||||
const cells = ENVS.flatMap((env) => terms.map((t) => ({ env, t, filled: Boolean(row.price_ids?.[env]?.[t]) })));
|
||||
const filled = cells.filter((c) => c.filled).length;
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{cells.map((c) => (
|
||||
<span key={`${c.env}-${c.t}`} title={`${c.env} ${c.t}`} className={["block h-2.5 w-2.5 rounded-[1px] border", c.filled ? "border-valid bg-valid" : "border-rule bg-panel-2"].join(" ")} />
|
||||
))}
|
||||
<span className="ml-1.5 font-mono text-[0.62rem] tracking-[0.08em] text-ink-3">
|
||||
{filled}/{cells.length} priced
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentRow({ row, scopeLabel }: { row: CatalogueRow; scopeLabel: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState<CatalogueRow["price_ids"] | null>(null);
|
||||
const ids = draft ?? row.price_ids ?? {};
|
||||
const dirty = JSON.stringify(ids) !== JSON.stringify(row.price_ids ?? {});
|
||||
const terms = termsFor(row);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.staff.updateCatalogue({ ...row, price_ids: ids }),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
qc.invalidateQueries({ queryKey: ["staff", "catalogue"] });
|
||||
},
|
||||
});
|
||||
|
||||
const set = (env: string, term: Term, value: string) =>
|
||||
setDraft({ ...ids, [env]: { ...(ids[env] ?? {}), [term]: value } });
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 border-t border-rule-soft pt-3 first:border-0 first:pt-0 md:grid-cols-[minmax(0,17rem)_1fr]">
|
||||
<div className="grid content-start gap-1.5">
|
||||
<span className="text-[0.9rem] font-semibold">{componentLabel(row)}</span>
|
||||
<span className={["w-max rounded border px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.1em]", row.scope === "shared" ? "border-accent text-accent" : "border-rule text-ink-3"].join(" ")}>{scopeLabel}</span>
|
||||
<span className="text-[0.78rem] text-ink-3">{componentDetail(row)}</span>
|
||||
<Coverage row={{ ...row, price_ids: ids }} terms={terms} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{ENVS.map((env) => (
|
||||
<div key={env} className="grid content-start gap-1.5">
|
||||
<span className="flex items-center gap-2 font-mono text-[0.62rem] uppercase tracking-[0.12em] text-ink-3">
|
||||
{env}
|
||||
<span className="h-px flex-1 bg-rule-soft" />
|
||||
</span>
|
||||
{terms.map((t) => (
|
||||
<label key={t} className="grid gap-1">
|
||||
<span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3">{t}</span>
|
||||
<input
|
||||
value={ids[env]?.[t] ?? ""}
|
||||
placeholder="pri_…"
|
||||
onChange={(e) => set(env, t, e.target.value)}
|
||||
className={["w-full rounded border bg-panel-2 px-2 py-1.5 font-mono text-[0.76rem] text-ink focus:border-accent focus:outline-none", ids[env]?.[t] ? "border-rule" : "border-dashed border-rule"].join(" ")}
|
||||
aria-label={`${componentLabel(row)} ${env} ${t} price ID`}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<button type="button" disabled={!dirty || save.isPending} onClick={() => save.mutate()} 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.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{save.error && <span className="text-[0.78rem] text-expired">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* The catalogue half of /staff/pricing: every priceable component, grouped by
|
||||
* what it is rather than by which plan sells it.
|
||||
*/
|
||||
export function CatalogueSection() {
|
||||
const { data: rows = [], isLoading } = useQuery({
|
||||
queryKey: ["staff", "catalogue"],
|
||||
queryFn: api.staff.catalogue,
|
||||
});
|
||||
|
||||
const shared = sharedRows(rows);
|
||||
const bases = planRows(rows);
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<SectionHeading
|
||||
title="Catalogue"
|
||||
note="Every priceable component, grouped by what it is rather than by which plan sells it. This is the only place a Paddle price ID lives."
|
||||
/>
|
||||
|
||||
<div className="grid gap-1.5 rounded border-l-2 border-accent bg-accent-wash px-3 py-2.5 text-[0.82rem] text-ink-2">
|
||||
<p>An add-on is one Paddle product sold to every paid plan, so its price is typed once. Only the base fee differs by plan, because only the base fee is a different product per plan.</p>
|
||||
<p>A component with no price ID is free — a feature with no price is a toggle a customer may take at no charge. Free is priced by nothing and has no rows at all, which is what keeps it outside Paddle. Changing a price affects the next checkout only; it cannot touch an issued licence.</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading…</p>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
<Panel title="Add-ons" meta={`${shared.length} rows · every paid plan`}>
|
||||
{shared.map((r) => (
|
||||
<ComponentRow key={rowKey(r)} row={r} scopeLabel="All paid plans" />
|
||||
))}
|
||||
</Panel>
|
||||
<Panel title="Base fee" meta={`${bases.length} rows · one per plan`}>
|
||||
{bases.map((r) => (
|
||||
<ComponentRow key={rowKey(r)} row={r} scopeLabel="This plan only" />
|
||||
))}
|
||||
</Panel>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api, type Deployment, type Plan } from "@/lib/api";
|
||||
import { featureDesc, featureLabel, FEATURE_LABEL } from "@/lib/features";
|
||||
import { limitLabel } from "@/lib/format";
|
||||
import { Button, controlClass } from "@/components/Button";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { SectionHeading } from "./SectionHeading";
|
||||
import { Modal } from "@/components/Modal";
|
||||
|
||||
const SUPPORT_LEVELS = [
|
||||
{ value: "community", label: "Community" },
|
||||
{ value: "email_24_5", label: "Email, 24/5" },
|
||||
{ value: "email_call_24_7", label: "Email + call, 24/7" },
|
||||
] as const;
|
||||
|
||||
const LIMIT_FIELDS = [
|
||||
{ key: "max_servers", label: "Servers" },
|
||||
{ key: "max_monitors", label: "Monitors" },
|
||||
{ key: "max_secret_groups", label: "Secret groups" },
|
||||
{ key: "max_channels", label: "Channels" },
|
||||
{ key: "audit_retention_days", label: "Audit history (days)" },
|
||||
] as const;
|
||||
|
||||
const FEATURE_KEYS = Object.keys(FEATURE_LABEL);
|
||||
|
||||
const planKey = (p: Plan) => `${p.deployment}/${p.tier}`;
|
||||
|
||||
/*
|
||||
* The list is tiers, and a tier's settings are behind a button.
|
||||
*
|
||||
* Six plans with five number fields, a select, a checkbox and four toggles each
|
||||
* is forty-odd controls on one screen, and the page it made could not be read
|
||||
* for the thing it exists to answer: what does each tier give you. The card
|
||||
* answers that; the modal is where it is changed.
|
||||
*/
|
||||
function TierCard({ plan, onOpen }: { plan: Plan; onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={[
|
||||
"grid w-full gap-2.5 rounded border bg-panel p-3.5 text-left",
|
||||
"transition-[border-color,transform] duration-150 hover:-translate-y-px hover:border-accent",
|
||||
plan.active ? "border-rule" : "border-dashed border-rule opacity-75",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[1rem] font-semibold">{plan.name}</span>
|
||||
{!plan.active && <span className="rounded border border-warn px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.1em] text-warn">Not offered</span>}
|
||||
<span className="ml-auto font-mono text-[0.68rem] text-ink-3">{planKey(plan)}</span>
|
||||
</span>
|
||||
|
||||
<dl className="grid grid-cols-[1fr_auto] gap-x-3 gap-y-0.5 text-[0.82rem]">
|
||||
<dt className="text-ink-3">Servers</dt>
|
||||
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.max_servers)}</dd>
|
||||
<dt className="text-ink-3">Monitors</dt>
|
||||
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.max_monitors)}</dd>
|
||||
<dt className="text-ink-3">Audit history</dt>
|
||||
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.audit_retention_days)} days</dd>
|
||||
</dl>
|
||||
|
||||
{/* Every feature key, lit or unlit — an absent chip cannot be told
|
||||
* from a feature nobody has heard of, and no tier bundles one today,
|
||||
* so the unlit row IS the information. */}
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{FEATURE_KEYS.map((k) => {
|
||||
const on = plan.base_features.includes(k);
|
||||
return (
|
||||
<span key={k} className={["rounded border px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.06em]", on ? "border-valid text-valid" : "border-rule text-ink-3"].join(" ")}>
|
||||
{featureLabel(k)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
|
||||
<span className="justify-self-start rounded border border-accent px-2.5 py-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-accent">Open plan</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* -1 is Unlimited everywhere in the licence payload, so the form takes it
|
||||
* literally rather than inventing a checkbox. A staff screen that hides the
|
||||
* sentinel is a staff screen where nobody can tell whether a plan says
|
||||
* unlimited or nothing at all. */
|
||||
function PlanModal({ plan, onClose, onSave }: { plan: Plan; onClose: () => void; onSave: (next: Plan) => void }) {
|
||||
const [draft, setDraft] = useState<Plan>(plan);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
const toggleFeature = (key: string, on: boolean) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
base_features: on ? [...draft.base_features, key] : draft.base_features.filter((f) => f !== key),
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={plan.name}
|
||||
meta={planKey(plan)}
|
||||
footer={
|
||||
<>
|
||||
<p className="mr-auto max-w-md text-[0.78rem] text-ink-3">Applies to licences issued from now on. Issued licences snapshotted their plan and are unaffected.</p>
|
||||
<Button type="button" variant="line" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" disabled={!dirty} onClick={() => onSave(draft)}>
|
||||
Save plan
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<section className="grid gap-2">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Base limits</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="grid gap-1">
|
||||
<span className="text-[0.78rem] text-ink-3">{f.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
base_limits: { ...draft.base_limits, [f.key]: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
className={controlClass("h-9 text-[0.84rem] tabular-nums")}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[0.78rem] text-ink-3">−1 is unlimited. A metered dimension starts here and the customer buys upward from it.</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Base features</span>
|
||||
<div className="grid gap-1.5">
|
||||
{FEATURE_KEYS.map((k) => {
|
||||
const on = draft.base_features.includes(k);
|
||||
return (
|
||||
<label key={k} className="flex items-center gap-2.5 rounded border border-rule-soft bg-panel-2 px-2.5 py-2">
|
||||
<input type="checkbox" checked={on} onChange={(e) => toggleFeature(k, e.target.checked)} />
|
||||
<span>
|
||||
<span className="block text-[0.86rem]">{featureLabel(k)}</span>
|
||||
<span className="block text-[0.75rem] text-ink-3">{featureDesc(k)}</span>
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-3">{on ? "Included" : "Sold as add-on"}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[0.78rem] text-ink-3">No tier bundles a feature today. Including one here grants it with the plan and removes it from the customer's purchase form.</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Availability</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[0.78rem] text-ink-3">Support level</span>
|
||||
<select value={draft.support_level} onChange={(e) => setDraft({ ...draft, support_level: e.target.value })} className={controlClass("h-9 text-[0.84rem]")}>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 self-end pb-2 text-[0.86rem]">
|
||||
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
|
||||
Offered to customers
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* The plans half of /staff/pricing. It is a section rather than a page because
|
||||
* a tier's allowances and a tier's price are one decision made in one sitting,
|
||||
* and they were two screens with no view showing both.
|
||||
*/
|
||||
export function PlansSection() {
|
||||
const qc = useQueryClient();
|
||||
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
|
||||
const licenses = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
|
||||
|
||||
/* Two pieces of state, not one: `editing` is the plan whose modal is open,
|
||||
* `confirming` is the edit awaiting the change summary. Collapsing them put
|
||||
* the confirmation behind the modal it was confirming. */
|
||||
const [editing, setEditing] = useState<Plan | null>(null);
|
||||
const [confirming, setConfirming] = useState<Plan | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["plans"] });
|
||||
setConfirming(null);
|
||||
},
|
||||
});
|
||||
|
||||
const original = plans.data?.find((p) => p.deployment === confirming?.deployment && p.tier === confirming?.tier);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<SectionHeading title="Plans" note="What each tier grants. Open a tier to change its base limits and features. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence." />
|
||||
|
||||
{confirming && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={confirming}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === confirming.tier && l.deployment === confirming.deployment).length}
|
||||
onConfirm={() => save.mutate(confirming)}
|
||||
onCancel={() => setConfirming(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="grid gap-2.5">
|
||||
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-hosted"}</h2>
|
||||
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<TierCard key={planKey(p)} plan={p} onOpen={() => setEditing(p)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{editing && (
|
||||
<PlanModal
|
||||
key={planKey(editing)}
|
||||
plan={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={(next) => {
|
||||
setEditing(null);
|
||||
setConfirming(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* The heading that separates the two halves of /staff/pricing.
|
||||
*
|
||||
* It is not PageHeader: the page has one of those, and a second title-sized
|
||||
* heading under it would read as a second page. This is the same mono eyebrow
|
||||
* idiom the deployment groups use, one level up.
|
||||
*/
|
||||
export function SectionHeading({ title, note }: { title: string; note: string }) {
|
||||
return (
|
||||
<div className="grid gap-1 border-b border-rule pb-2">
|
||||
<h2 className="text-[1.05rem] font-bold tracking-[-0.01em]">{title}</h2>
|
||||
<p className="max-w-[68ch] text-[0.84rem] text-ink-3">{note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { CatalogueSection } from "./CatalogueSection";
|
||||
import { PlansSection } from "./PlansSection";
|
||||
|
||||
/*
|
||||
* Plans and catalogue on one page.
|
||||
*
|
||||
* They were two nav entries, and the split asked staff to hold one half in
|
||||
* their head while looking at the other: a tier's allowances decide what the
|
||||
* metered component charges for, and the base fee is meaningless without the
|
||||
* allowance it includes. One page, two sections, in the order the decision is
|
||||
* made — what a tier grants, then what it costs.
|
||||
*/
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<div className="grid gap-7">
|
||||
<PageHeader title="Pricing" back={{ href: "/staff", label: "Operations" }} subtitle="What each tier grants, and what every priceable component costs." />
|
||||
<PlansSection />
|
||||
<CatalogueSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/*
|
||||
* A native <dialog>, not a div with a fixed overlay.
|
||||
*
|
||||
* showModal() gives focus trapping, inert background, Escape and the top layer
|
||||
* for free — all four are things a hand-rolled overlay gets wrong, and the third
|
||||
* is the one staff will actually reach for. The only wiring needed is keeping
|
||||
* React state and the element's open state in step, and routing every close —
|
||||
* Escape, backdrop, button — through one onClose.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
meta,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
meta?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (open && !el.open) el.showModal();
|
||||
if (!open && el.open) el.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
/* Clicking the backdrop hits the dialog element itself, never a
|
||||
* child — so this closes on backdrop and not on content. */
|
||||
onClick={(e) => {
|
||||
if (e.target === ref.current) onClose();
|
||||
}}
|
||||
className="w-[min(44rem,94vw)] rounded border border-rule bg-panel p-0 text-ink shadow-lg backdrop:bg-[rgba(4,12,24,0.55)]"
|
||||
>
|
||||
<header className="flex flex-wrap items-center gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<h2 className="text-[1.02rem] font-bold tracking-[-0.01em]">{title}</h2>
|
||||
{meta && <span className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">{meta}</span>}
|
||||
<button type="button" onClick={onClose} className="ml-auto rounded border border-rule px-2 py-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-2 hover:border-ink-3" aria-label="Close">
|
||||
Esc
|
||||
</button>
|
||||
</header>
|
||||
<div className="grid max-h-[68vh] gap-4 overflow-y-auto p-4">{children}</div>
|
||||
{footer && <footer className="flex flex-wrap items-center gap-3 border-t border-rule-soft bg-panel-2 px-4 py-3">{footer}</footer>}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
import { rowsForPlan } from "@/lib/catalogue";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export interface PlanChoice {
|
||||
@@ -50,7 +51,7 @@ export default function PlanConfigurator({
|
||||
);
|
||||
const plan = available.find((p) => p.tier === value.tier);
|
||||
const rows = useMemo(
|
||||
() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier),
|
||||
() => rowsForPlan(catalogue, deployment, value.tier),
|
||||
[catalogue, deployment, value.tier],
|
||||
);
|
||||
const featureRows = rows.filter((r) => r.kind === "feature");
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* customer-facing and should be shown verbatim).
|
||||
*/
|
||||
|
||||
/* catalogue.ts imports only types from here, so this is not a cycle. */
|
||||
import { rowsForPlan } from "@/lib/catalogue";
|
||||
|
||||
export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
export class NotConnected extends Error {
|
||||
@@ -176,6 +179,11 @@ export interface Plan {
|
||||
|
||||
export interface CatalogueRow {
|
||||
kind: "base" | "limit" | "feature";
|
||||
/* "plan" rows carry a deployment and tier and belong to that plan alone.
|
||||
* "shared" rows leave both empty and are sold by every paid plan, which is
|
||||
* why a price ID is typed once rather than four times. Read them through
|
||||
* rowsForPlan in lib/catalogue, never by filtering on deployment. */
|
||||
scope: "plan" | "shared";
|
||||
deployment: Deployment;
|
||||
tier: Tier;
|
||||
limit_key?: string;
|
||||
@@ -213,9 +221,7 @@ export function lineItemsFor(
|
||||
const env = opts.env;
|
||||
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
|
||||
if (!plan) return [];
|
||||
const rows = opts.catalogue.filter(
|
||||
(r) => r.deployment === deployment && r.tier === choice.tier,
|
||||
);
|
||||
const rows = rowsForPlan(opts.catalogue, deployment, choice.tier);
|
||||
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
|
||||
const base = plan.base_limits.max_servers;
|
||||
const items: { priceId: string; quantity: number }[] = [];
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CatalogueRow, Deployment, Tier } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* rowsForPlan is the TypeScript half of Go's models.CatalogueFor, and the two
|
||||
* must change together — the same shape of hazard as web/lib/targets.ts.
|
||||
*
|
||||
* A plan sells its own base row plus every shared add-on row. Shared rows leave
|
||||
* deployment and tier empty, so the filter this replaced — `r.deployment === dep
|
||||
* && r.tier === tier` — now returns a plan priced by its base fee and nothing
|
||||
* else. There were five copies of that filter; this is why it is a module.
|
||||
*/
|
||||
export function rowsForPlan(
|
||||
catalogue: CatalogueRow[],
|
||||
deployment: Deployment,
|
||||
tier: Tier,
|
||||
): CatalogueRow[] {
|
||||
return catalogue.filter(
|
||||
(r) => r.scope === "shared" || (r.deployment === deployment && r.tier === tier),
|
||||
);
|
||||
}
|
||||
|
||||
/* Every add-on a paid plan can be sold, in one list. The staff catalogue editor
|
||||
* shows these once; the purchase form reads them per plan through rowsForPlan. */
|
||||
export function sharedRows(catalogue: CatalogueRow[]): CatalogueRow[] {
|
||||
return catalogue.filter((r) => r.scope === "shared");
|
||||
}
|
||||
|
||||
/* The base fee rows, which are genuinely one per plan because each is its own
|
||||
* Paddle product at its own price. */
|
||||
export function planRows(catalogue: CatalogueRow[]): CatalogueRow[] {
|
||||
return catalogue.filter((r) => r.scope !== "shared");
|
||||
}
|
||||
|
||||
/* A stable identity for a row, used as a React key and as the draft key in the
|
||||
* staff editor. Mirrors the natural key the API addresses a row by. */
|
||||
export function rowKey(r: CatalogueRow): string {
|
||||
return [r.scope ?? "plan", r.deployment ?? "", r.tier ?? "", r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
|
||||
}
|
||||
@@ -10,12 +10,14 @@ 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 {
|
||||
|
||||
@@ -8,6 +8,14 @@ import type { NextConfig } from "next";
|
||||
*/
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
/* Plans and catalogue became one page. Both old paths are bookmarked in
|
||||
* staff browsers, so they redirect rather than 404. */
|
||||
async redirects() {
|
||||
return [
|
||||
{ source: "/staff/plans", destination: "/staff/pricing", permanent: true },
|
||||
{ source: "/staff/catalogue", destination: "/staff/pricing", permanent: true },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
+4
-3
@@ -3,14 +3,15 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/agent
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.20.0
|
||||
golang.org/x/sys v0.47.0
|
||||
google.golang.org/grpc v1.64.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
)
|
||||
|
||||
+8
-6
@@ -1,11 +1,13 @@
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
|
||||
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
type streamWriter struct {
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/codec"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
encoding.RegisterCodec(JSONCodec{})
|
||||
encoding.RegisterCodec(codec.JSONCodec{})
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package grpcclient
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type JSONCodec struct{}
|
||||
|
||||
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (JSONCodec) Name() string {
|
||||
return "proto"
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type RegisterRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
PreRegToken string `json:"pre_reg_token"`
|
||||
Hostname string `json:"hostname"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
OsInfo string `json:"os_info"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
|
||||
type SyncRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Label string `json:"label"`
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
}
|
||||
|
||||
type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
}
|
||||
|
||||
type ReportUpdatesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Updates []PackageUpdate `json:"updates"`
|
||||
}
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
}
|
||||
type MemReport struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type PartitionReport struct {
|
||||
Device string `json:"device"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
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"`
|
||||
RebootRequired bool `json:"reboot_required,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ExpectedStatus int `json:"expected_status,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
TLSWarnDays int `json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
IntervalSec int `json:"interval_sec"`
|
||||
Retries int `json:"retries"`
|
||||
}
|
||||
type SyncMonitorsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
type SyncMonitorsResponse struct {
|
||||
Monitors []MonitorSpec `json:"monitors,omitempty"`
|
||||
}
|
||||
type CheckResult struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
}
|
||||
type ReportChecksRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Results []CheckResult `json:"results,omitempty"`
|
||||
}
|
||||
type ReportChecksResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type OpenProxyCmd struct {
|
||||
ProxyId string `json:"proxy_id"`
|
||||
Port uint32 `json:"port"`
|
||||
}
|
||||
|
||||
type ProxyOpen struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
ProxyId string `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type ProxyClose struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyClientMsg struct {
|
||||
Open *ProxyOpen `json:"open,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ProxyServerMsg struct {
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Close *ProxyClose `json:"close,omitempty"`
|
||||
}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type UpdateAgentCmd struct {
|
||||
Version string `json:"version"`
|
||||
GiteaBaseURL string `json:"gitea_base_url"`
|
||||
}
|
||||
|
||||
type GenerateKeyCmd struct {
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
KeySize int `json:"key_size,omitempty"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
|
||||
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
|
||||
type CommandResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RunStepCmd struct {
|
||||
Interpreter string `json:"interpreter"`
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
OutputEnv map[string]string `json:"output_env,omitempty"`
|
||||
}
|
||||
|
||||
type StepOutputChunk struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Seq uint64 `json:"seq"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageCommandStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
m := new(ServerCommand)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
Recv() (*AgentMessage, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type keyManagerCommandStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
m := new(AgentMessage)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamServer interface {
|
||||
Send(*ProxyServerMsg) error
|
||||
Recv() (*ProxyClientMsg, error)
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error {
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) {
|
||||
m := new(ProxyClientMsg)
|
||||
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type Vantage_ProxyStreamClient interface {
|
||||
Send(*ProxyClientMsg) error
|
||||
Recv() (*ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type vantageProxyStreamClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error {
|
||||
return c.ClientStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) {
|
||||
m := new(ProxyServerMsg)
|
||||
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type VantageClient interface {
|
||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||
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)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error)
|
||||
}
|
||||
|
||||
type UnimplementedVantageServer struct{}
|
||||
|
||||
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "not implemented")
|
||||
}
|
||||
|
||||
type keyManagerClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
|
||||
return &keyManagerClient{cc}
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
|
||||
out := new(RegisterResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
|
||||
out := new(SyncResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
|
||||
out := new(UploadKeyResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
|
||||
out := new(ReportUpdatesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
|
||||
out := new(SyncMonitorsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
|
||||
out := new(ReportChecksResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageCommandStreamClient{stream}, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "ProxyStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/ProxyStream", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vantageProxyStreamClient{stream}, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
// without it this file compiles on Linux too and collides with collect_linux.go.
|
||||
package inventory
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package inventory
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/checker"
|
||||
"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/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
const syncInterval = 30 * time.Second
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
// collectPackagesFlag is written by the 30s key poll and read by the hourly
|
||||
|
||||
@@ -20,12 +20,12 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
agentexec "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/exec"
|
||||
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/inventory"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
|
||||
agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
"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"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
|
||||
)
|
||||
|
||||
// workloadInterval is the report cadence. Sixty seconds is affordable because
|
||||
|
||||
@@ -19,12 +19,16 @@ func Run(ctx context.Context, script string) (string, error) {
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -12,8 +12,16 @@ const servicesTimeout = 60 * time.Second
|
||||
|
||||
const servicesScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$svcs = Get-CimInstance Win32_Service |
|
||||
Select-Object Name,DisplayName,State,StartMode,PathName,ExitCode
|
||||
$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
|
||||
`
|
||||
|
||||
|
||||
@@ -113,10 +113,15 @@ func parseServices(jsonText, systemRoot string) ([]Workload, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
state := "stopped"
|
||||
// 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 = "running"
|
||||
state = "active"
|
||||
case failed:
|
||||
state = "failed"
|
||||
}
|
||||
@@ -189,7 +194,10 @@ func parseEvents(jsonText, serviceName, displayName string, tail int) (string, e
|
||||
continue
|
||||
}
|
||||
}
|
||||
msg := strings.TrimSpace(strings.ReplaceAll(e.M, "\r\n", " "))
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,12 @@ func TestParseServicesFilters(t *testing.T) {
|
||||
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "running" {
|
||||
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 != "stopped" {
|
||||
t.Errorf("Fabrikam state = %q, want stopped", byID["Fabrikam"].State)
|
||||
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" {
|
||||
@@ -80,15 +80,15 @@ func TestParseServicesExitCode1077(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].State != "stopped" {
|
||||
t.Fatalf("got %+v, want one stopped workload", got)
|
||||
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 {
|
||||
if err != nil || len(got) != 1 || got[0].State != "active" {
|
||||
t.Fatalf("single object: got %+v, err %v", got, err)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,25 @@ func TestParseEventsFormatsAndOrders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -2,5 +2,5 @@ apiVersion: v2
|
||||
name: vantage
|
||||
description: Helm chart for the Vantage stack (Redis, MongoDB, guacd, server, web)
|
||||
type: application
|
||||
version: 1.0.8
|
||||
version: 1.1.0
|
||||
appVersion: "1.0.8"
|
||||
|
||||
@@ -41,12 +41,9 @@ Ingress (Traefik):
|
||||
{{- range .Values.ingress.web.extraHosts }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.api.enabled }}
|
||||
{{ join ", " .Values.ingress.api.paths }} go straight to the server; everything else to web.
|
||||
{{- else }}
|
||||
Everything goes to web, which proxies /api and /auth onward. Set
|
||||
ingress.api.enabled=true to route them at the edge instead.
|
||||
{{- end }}
|
||||
{{ join ", " .Values.ingress.api.paths }} go to the server; everything else to web.
|
||||
web proxies nothing, so those paths must be routed here or by a terminator
|
||||
in front of this ingress.
|
||||
{{- if .Values.ingress.grpc.enabled }}
|
||||
- Agents: {{ .Values.ingress.grpc.host }} (gRPC, h2c behind TLS)
|
||||
Agents dial server.env.grpcHost, currently {{ tpl .Values.server.env.grpcHost . }}.
|
||||
@@ -70,3 +67,13 @@ or add an Ingress on top of the -web and -server services.
|
||||
Quick access via port-forward, e.g.:
|
||||
kubectl port-forward svc/{{ .Release.Name }}-web {{ .Values.web.service.port }}:{{ .Values.web.service.port }}
|
||||
kubectl port-forward svc/{{ .Release.Name }}-server {{ .Values.server.service.httpPort }}:{{ .Values.server.service.httpPort }}
|
||||
{{- if not .Values.backup.enabled }}
|
||||
|
||||
No backups are scheduled. Vantage encrypts SSH private keys, vault secrets and
|
||||
SSO client secrets with KEY_ENCRYPTION_KEY, and that key is not stored anywhere
|
||||
but your own configuration — a database restored without it is permanently
|
||||
unreadable.
|
||||
|
||||
Set backup.enabled, backup.image and backup.pvcName, and store
|
||||
KEY_ENCRYPTION_KEY somewhere that survives this cluster.
|
||||
{{- end }}
|
||||
|
||||
@@ -72,6 +72,8 @@ both read it.
|
||||
value: {{ .Values.server.env.proxyAdvertiseHost | quote }}
|
||||
- name: PROXY_LISTEN_HOST
|
||||
value: {{ .Values.server.env.proxyListenHost | quote }}
|
||||
- name: TRUSTED_PROXIES
|
||||
value: {{ .Values.server.env.trustedProxies | quote }}
|
||||
{{- if eq .Values.server.env.deploymentType "cloud" }}
|
||||
- name: VANTAGE_DEPLOYMENT
|
||||
value: "cloud"
|
||||
@@ -83,3 +85,18 @@ both read it.
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
vantage.backup.env renders the environment vantagectl needs.
|
||||
|
||||
It reads the SAME values the server does rather than taking its own, because a
|
||||
backup that connected to a different database, or stamped a fingerprint of a
|
||||
different key, than the deployment it is backing up would be worse than no
|
||||
backup: it would look like one.
|
||||
*/}}
|
||||
{{- define "vantage.backup.env" -}}
|
||||
- name: MONGO_URI
|
||||
value: {{ tpl .Values.server.env.mongoUri . | quote }}
|
||||
- name: KEY_ENCRYPTION_KEY
|
||||
value: {{ .Values.server.env.keyEncryptionKey | quote }}
|
||||
{{- end -}}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{{- if .Values.backup.enabled }}
|
||||
{{- if not .Values.backup.pvcName }}
|
||||
{{- fail "backup.enabled requires backup.pvcName: a backup needs somewhere durable to land, and the chart cannot guess where that is" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.backup.image }}
|
||||
{{- fail "backup.enabled requires backup.image: the vantagectl image to run" }}
|
||||
{{- end }}
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: {{ include "vantage.fullname" . }}-backup
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: backup
|
||||
spec:
|
||||
schedule: {{ .Values.backup.schedule | quote }}
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: {{ .Values.backup.successfulJobsHistoryLimit }}
|
||||
failedJobsHistoryLimit: {{ .Values.backup.failedJobsHistoryLimit }}
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "vantage.labels" . | nindent 12 }}
|
||||
app.kubernetes.io/component: backup
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: vantagectl
|
||||
image: {{ .Values.backup.image | quote }}
|
||||
args:
|
||||
- backup
|
||||
- --out
|
||||
- /backups
|
||||
{{- with .Values.backup.exclude }}
|
||||
- --exclude
|
||||
- {{ join "," . | quote }}
|
||||
{{- end }}
|
||||
env:
|
||||
# Referenced, never redeclared. A backup job holding its own
|
||||
# copy of KEY_ENCRYPTION_KEY is a second place for it to be
|
||||
# wrong, and the fingerprint it stamps would then be a
|
||||
# fingerprint of the wrong key.
|
||||
{{- include "vantage.backup.env" . | nindent 16 }}
|
||||
volumeMounts:
|
||||
- name: backups
|
||||
mountPath: /backups
|
||||
resources:
|
||||
{{- toYaml .Values.backup.resources | nindent 16 }}
|
||||
volumes:
|
||||
- name: backups
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.backup.pvcName | quote }}
|
||||
{{- end }}
|
||||
@@ -2,16 +2,14 @@
|
||||
{{/*
|
||||
Two hostnames, because the two audiences arrive over different protocols.
|
||||
|
||||
Browsers reach the web host. What answers there depends on the path: with
|
||||
ingress.api.enabled, /api and /auth go straight to the server and everything
|
||||
else goes to `web`. Without it, everything goes to `web`, which proxies those
|
||||
prefixes onward itself (web/next.config.ts).
|
||||
Browsers reach the web host, and the path decides what answers: /api, /auth,
|
||||
/public, /install* and /update* go to the server, everything else to `web`.
|
||||
|
||||
Both work. Routing at the edge is one hop shorter and is what the Nginx Proxy
|
||||
Manager deployment in front of the Docker install already does, so leaving it
|
||||
off changes the shape of the request path between the two deployments. It is
|
||||
still off by default, because turning it on where `web` is the only thing with
|
||||
a public certificate would strand /api behind a route nobody can reach.
|
||||
That split is not optional and ingress.api.enabled defaults to true. `web`
|
||||
proxies nothing — it holds no address for the server at all — so with these
|
||||
paths absent the UI loads and every request it makes 404s against Next. The
|
||||
setting remains a value only so an installation terminating in front of this
|
||||
ingress can route the prefixes itself; it must be routed somewhere.
|
||||
|
||||
The web host is normally a wildcard — `*.vantage.example.com` — because that is
|
||||
the per-tenant instance namespace; APP_ROOT_LABEL resolves the instance from the
|
||||
@@ -35,6 +33,9 @@ its HTTP port too.
|
||||
{{- if and .Values.ingress.api.enabled (not $apiPaths) }}
|
||||
{{- fail "ingress.api.enabled requires at least one path in ingress.api.paths" }}
|
||||
{{- end }}
|
||||
{{- if not .Values.ingress.api.enabled }}
|
||||
{{- fail "ingress.api.enabled=false leaves /api, /auth and /public unrouted: web proxies nothing. Route those prefixes to the server at your own terminator, or leave this enabled." }}
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
|
||||
@@ -38,12 +38,10 @@ spec:
|
||||
image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}"
|
||||
ports:
|
||||
- containerPort: {{ .Values.web.service.port }}
|
||||
env:
|
||||
- name: API_URL
|
||||
value: {{ tpl .Values.web.env.apiUrl . | quote }}
|
||||
# /healthz is served by this Next process; /api is rewritten to the
|
||||
# server, so a probe there would report the backend's health and keep
|
||||
# passing while this pod was wedged.
|
||||
# /healthz is served by this Next process. /api never reaches this
|
||||
# pod at all — the ingress routes it to the server — so there is no
|
||||
# backend address to configure and no probe here that could report
|
||||
# the backend's health by accident.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
|
||||
@@ -63,6 +63,7 @@ server:
|
||||
appRootLabel: vantage
|
||||
proxyAdvertiseHost: "{{ .Release.Name }}-server"
|
||||
proxyListenHost: "0.0.0.0"
|
||||
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
persistence:
|
||||
enabled: false
|
||||
size: 1Gi
|
||||
@@ -78,8 +79,6 @@ web:
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
env:
|
||||
apiUrl: "http://{{ .Release.Name }}-server:8080"
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
@@ -89,11 +88,14 @@ ingress:
|
||||
web:
|
||||
host: ""
|
||||
extraHosts: []
|
||||
# Not optional: web proxies nothing, so these prefixes reach the server
|
||||
# only through this ingress. Turning it off serves the UI with a dead API.
|
||||
api:
|
||||
enabled: false
|
||||
enabled: true
|
||||
paths:
|
||||
- /api/
|
||||
- /auth/
|
||||
- /public/
|
||||
- /update
|
||||
- /install
|
||||
- /update.ps1
|
||||
@@ -109,3 +111,25 @@ ingress:
|
||||
certResolver: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
# Scheduled backups.
|
||||
#
|
||||
# Off by default, deliberately. A backup with nowhere durable to land is a
|
||||
# false sense of safety, and the chart cannot know where that is — pvcName
|
||||
# must name a volume you have decided will outlive the cluster.
|
||||
#
|
||||
# There is no restore manifest here on purpose: a restore is an operator
|
||||
# decision with a confirmation attached, and must never be something a
|
||||
# `helm upgrade` can trigger. Run one as a `kubectl run` Job with
|
||||
# --confirm-db.
|
||||
backup:
|
||||
enabled: false
|
||||
schedule: "0 2 * * *"
|
||||
image: ""
|
||||
pvcName: ""
|
||||
# Collections to leave out. Recorded in each archive's manifest, so an
|
||||
# archive can never claim to be complete when it is not.
|
||||
exclude: []
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
resources: {}
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
PROXY_ADVERTISE_HOST: server
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -59,8 +60,10 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
API_URL: ${API_URL:-http://server:8080}
|
||||
# No API_URL: web proxies nothing. The reverse proxy in front of this
|
||||
# deployment must route /api, /auth, /public, /install*, /update* to
|
||||
# server:8080 and everything else to web:3000. Reaching web:3000
|
||||
# directly serves the UI and every API call 404s.
|
||||
depends_on:
|
||||
- server
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
<title>Vantage Status Pages</title>
|
||||
<style>
|
||||
:root{
|
||||
color-scheme: dark;
|
||||
/* Vantage web/ dark tokens, copied verbatim from web/app/globals.css.
|
||||
This mockup commits to one theme because web/ does. */
|
||||
--ground:#071628; --panel:#0d2138; --panel-2:#102842; --well:#04101f;
|
||||
--ink:#e4ecf6; --ink-2:#9fb3ca; --ink-3:#71879f;
|
||||
--rule:#1e3855; --rule-soft:#172c44;
|
||||
--accent:#5b9be8; --accent-hover:#7fb2f0; --accent-ink:#04101f;
|
||||
--up:#4fb484; --pend:#d6a63f; --down:#e2705a; --logo:#7fb2f0;
|
||||
--shadow:0 1px 0 rgba(0,0,0,.35), 0 20px 44px -26px rgba(0,0,0,.85);
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--r:4px;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--ground);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;line-height:1.5}
|
||||
a{color:inherit}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
|
||||
.page{max-width:1180px;margin:0 auto;padding:48px 24px 96px;display:flex;flex-direction:column;gap:56px}
|
||||
|
||||
.lede h1{font-size:1.6rem;font-weight:800;letter-spacing:-.035em;text-wrap:balance}
|
||||
.lede p{color:var(--ink-2);font-size:.9rem;max-width:65ch;margin-top:8px}
|
||||
|
||||
.cap{font-family:var(--mono);font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2)}
|
||||
|
||||
.board{display:flex;flex-direction:column;gap:10px}
|
||||
.board__head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||
.board__route{font-family:var(--mono);font-size:.7rem;color:var(--ink-3)}
|
||||
.frame{border:1px solid var(--rule);border-radius:var(--r);background:var(--ground);box-shadow:var(--shadow);overflow:hidden}
|
||||
|
||||
/* address strip — shows the URL scheme being approved */
|
||||
.addr{display:flex;align-items:center;gap:10px;background:var(--well);border-bottom:1px solid var(--rule);padding:9px 14px}
|
||||
.addr__dots{display:flex;gap:5px}
|
||||
.addr__dots i{width:8px;height:8px;border-radius:999px;background:var(--rule);display:block}
|
||||
.addr__url{font-family:var(--mono);font-size:.72rem;color:var(--ink-2);overflow-x:auto;white-space:nowrap}
|
||||
.addr__url b{color:var(--ink);font-weight:600}
|
||||
.addr__tag{margin-left:auto;font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);border:1px solid var(--rule);border-radius:999px;padding:2px 8px;white-space:nowrap}
|
||||
|
||||
/* ---------- public status page ---------- */
|
||||
.pub{padding:40px 28px 32px}
|
||||
.pub__inner{max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:28px}
|
||||
.pub__head{display:flex;align-items:center;gap:14px}
|
||||
.mark{width:38px;height:38px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--rule);display:grid;place-items:center;color:var(--logo);font-family:var(--mono);font-weight:700;font-size:.85rem;flex-shrink:0}
|
||||
.pub__head h2{font-size:1.35rem;font-weight:800;letter-spacing:-.03em}
|
||||
.pub__head p{color:var(--ink-2);font-size:.85rem;margin-top:2px}
|
||||
|
||||
.overall{display:flex;align-items:center;gap:11px;border:1px solid;border-radius:var(--r);padding:14px 16px;font-weight:600;font-size:.95rem}
|
||||
.overall--down{background:rgba(226,112,90,.10);border-color:rgba(226,112,90,.30);color:var(--down)}
|
||||
.glyph{width:16px;height:16px;flex-shrink:0}
|
||||
|
||||
.banner{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r);padding:12px 14px;font-size:.85rem;color:var(--ink-2);display:flex;gap:10px}
|
||||
.banner b{color:var(--ink);font-weight:600}
|
||||
|
||||
.group{display:flex;flex-direction:column;gap:10px}
|
||||
.group > .cap{padding-left:2px}
|
||||
|
||||
.card{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r)}
|
||||
.rows > * + *{border-top:1px solid var(--rule-soft)}
|
||||
|
||||
.comp{padding:16px}
|
||||
.comp__top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:10px}
|
||||
.comp__name{font-weight:600;font-size:.92rem}
|
||||
.state{display:inline-flex;align-items:center;gap:7px;font-size:.78rem;color:var(--ink-2);white-space:nowrap}
|
||||
.dot{width:7px;height:7px;border-radius:999px;display:block;flex-shrink:0}
|
||||
.dot--up{background:var(--up)} .dot--down{background:var(--down)}
|
||||
.dot--maint{background:var(--accent)} .dot--pend{background:var(--pend)}
|
||||
.dot--none{background:var(--rule)}
|
||||
|
||||
.bar{display:flex;gap:2px;overflow-x:auto;padding-bottom:2px}
|
||||
.bar span{height:26px;width:3px;border-radius:999px;flex:0 0 auto;background:var(--rule)}
|
||||
.bar .up{background:var(--up)} .bar .down{background:var(--down)}
|
||||
.bar .maint{background:var(--accent)} .bar .none{background:var(--rule)}
|
||||
.scale{display:flex;justify-content:space-between;margin-top:7px;font-size:.7rem;color:var(--ink-3)}
|
||||
.scale b{color:var(--ink-2);font-weight:600;font-variant-numeric:tabular-nums}
|
||||
|
||||
.inc{padding:14px 16px}
|
||||
.inc__top{display:flex;align-items:baseline;justify-content:space-between;gap:14px}
|
||||
.inc__title{font-weight:600;font-size:.92rem}
|
||||
.inc__meta{font-size:.75rem;color:var(--ink-3);margin-top:3px}
|
||||
.inc__affects{font-size:.75rem;color:var(--ink-2);margin-top:5px}
|
||||
.pill{font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;border-radius:999px;padding:3px 9px;border:1px solid;white-space:nowrap}
|
||||
.pill--inv{color:var(--down);border-color:rgba(226,112,90,.35);background:rgba(226,112,90,.10)}
|
||||
.pill--mon{color:var(--pend);border-color:rgba(214,166,63,.35);background:rgba(214,166,63,.10)}
|
||||
.pill--res{color:var(--up);border-color:rgba(79,180,132,.35);background:rgba(79,180,132,.10)}
|
||||
.pill--sch{color:var(--accent);border-color:rgba(91,155,232,.35);background:rgba(91,155,232,.10)}
|
||||
.pill--draft{color:var(--ink-2);border-color:var(--rule);background:var(--panel-2)}
|
||||
.pill--live{color:var(--up);border-color:rgba(79,180,132,.35);background:rgba(79,180,132,.10)}
|
||||
|
||||
.timeline{margin-top:12px;border-left:1px solid var(--rule);padding-left:14px;display:flex;flex-direction:column;gap:12px}
|
||||
.tl__head{display:flex;align-items:baseline;gap:9px}
|
||||
.tl__st{font-family:var(--mono);font-size:.62rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2)}
|
||||
.tl__at{font-size:.7rem;color:var(--ink-3);font-variant-numeric:tabular-nums}
|
||||
.tl__body{font-size:.85rem;margin-top:3px;color:var(--ink)}
|
||||
|
||||
.pub__foot{text-align:center;font-size:.72rem;color:var(--ink-3);padding-top:6px}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
.app{display:grid;grid-template-columns:236px 1fr;min-height:660px}
|
||||
.side{background:var(--panel);border-right:1px solid var(--rule);display:flex;flex-direction:column}
|
||||
.side__brand{height:64px;display:flex;align-items:center;gap:12px;padding:0 20px;border-bottom:1px solid var(--rule);flex-shrink:0}
|
||||
.side__brand .mark{width:32px;height:32px;font-size:.78rem}
|
||||
.side__brand b{font-size:1rem;font-weight:800;letter-spacing:-.035em;display:block;line-height:1.2}
|
||||
.side__nav{padding:16px 12px;display:flex;flex-direction:column;gap:16px}
|
||||
.navgrp + .navgrp{border-top:1px solid var(--rule);padding-top:16px}
|
||||
.navgrp > .cap{padding:0 12px 6px}
|
||||
.navgrp ul{list-style:none;display:flex;flex-direction:column;gap:4px}
|
||||
.navgrp a{position:relative;display:flex;align-items:center;gap:12px;border-radius:var(--r);padding:9px 12px;font-size:.85rem;font-weight:500;color:var(--ink-2);text-decoration:none}
|
||||
.navgrp a:hover{background:var(--panel-2);color:var(--ink)}
|
||||
.navgrp a.on{background:var(--panel-2);color:var(--ink);font-weight:600}
|
||||
.navgrp a.on::before{content:"";position:absolute;left:0;top:4px;bottom:4px;width:2px;border-radius:999px;background:var(--accent)}
|
||||
.navgrp svg{width:16px;height:16px;flex-shrink:0;opacity:.9}
|
||||
|
||||
.main{padding:26px 28px 36px;display:flex;flex-direction:column;gap:22px;min-width:0}
|
||||
.back{font-size:.78rem;color:var(--ink-2);text-decoration:none;display:inline-flex;gap:6px;align-items:center}
|
||||
.back:hover{color:var(--ink)}
|
||||
.phead{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:wrap}
|
||||
.phead h2{font-size:1.3rem;font-weight:800;letter-spacing:-.03em}
|
||||
.record{display:flex;align-items:center;gap:8px;margin-top:6px}
|
||||
.record code{font-family:var(--mono);font-size:.72rem;color:var(--ink-2);background:var(--well);border:1px solid var(--rule);border-radius:var(--r);padding:3px 8px}
|
||||
.copy{background:none;border:0;color:var(--ink-3);cursor:pointer;font-size:.72rem;font-family:var(--mono)}
|
||||
.copy:hover{color:var(--accent)}
|
||||
.acts{display:flex;gap:9px;flex-wrap:wrap}
|
||||
.btn{font-size:.82rem;font-weight:600;border-radius:var(--r);padding:8px 14px;border:1px solid var(--rule);background:var(--panel);color:var(--ink);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:7px}
|
||||
.btn:hover{background:var(--panel-2)}
|
||||
.btn--p{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
|
||||
.btn--p:hover{background:var(--accent-hover)}
|
||||
|
||||
.panel{border:1px solid var(--rule);background:var(--panel);border-radius:var(--r)}
|
||||
.panel__head{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 16px;border-bottom:1px solid var(--rule)}
|
||||
.panel__head h3{font-size:.95rem;font-weight:700}
|
||||
.panel__head p{font-size:.76rem;color:var(--ink-3);margin-top:2px}
|
||||
.panel__body{padding:16px;display:flex;flex-direction:column;gap:16px}
|
||||
|
||||
.fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
|
||||
.field{display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.field > label{font-size:.76rem;font-weight:600;color:var(--ink-2)}
|
||||
.field .hint{font-size:.72rem;color:var(--ink-3)}
|
||||
.in{background:var(--well);border:1px solid var(--rule);border-radius:var(--r);padding:8px 11px;font:inherit;font-size:.85rem;color:var(--ink);width:100%}
|
||||
.in::placeholder{color:var(--ink-3)}
|
||||
.in:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
|
||||
.in--mono{font-family:var(--mono);font-size:.8rem}
|
||||
|
||||
.toggle{display:flex;align-items:center;justify-content:space-between;gap:16px;background:var(--panel-2);border:1px solid var(--rule);border-radius:var(--r);padding:12px 14px}
|
||||
.toggle p{font-size:.76rem;color:var(--ink-3);margin-top:3px;max-width:52ch}
|
||||
.toggle b{font-size:.85rem}
|
||||
.sw{width:38px;height:21px;border-radius:999px;background:var(--up);border:0;position:relative;cursor:pointer;flex-shrink:0}
|
||||
.sw::after{content:"";position:absolute;top:2px;left:19px;width:17px;height:17px;border-radius:999px;background:var(--accent-ink)}
|
||||
.sw[aria-checked="false"]{background:var(--rule)}
|
||||
.sw[aria-checked="false"]::after{left:2px;background:var(--ink-3)}
|
||||
|
||||
.sect{border:1px solid var(--rule);border-radius:var(--r);background:var(--panel-2)}
|
||||
.sect__head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--rule)}
|
||||
.sect__head .in{max-width:220px}
|
||||
.sect__head .rm{margin-left:auto}
|
||||
.rm{background:none;border:0;color:var(--ink-3);font-size:.75rem;cursor:pointer;font-family:var(--mono)}
|
||||
.rm:hover{color:var(--down)}
|
||||
.entry{display:grid;grid-template-columns:1fr 1fr auto;gap:12px;align-items:center;padding:11px 12px}
|
||||
.entry + .entry{border-top:1px solid var(--rule-soft)}
|
||||
.entry__mon{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.entry__mon b{font-size:.84rem;font-weight:600}
|
||||
.entry__mon span{font-family:var(--mono);font-size:.68rem;color:var(--ink-3)}
|
||||
.adds{display:flex;gap:9px;flex-wrap:wrap;padding:0 12px 12px}
|
||||
|
||||
.inc-row{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:13px 14px}
|
||||
.inc-row + .inc-row{border-top:1px solid var(--rule-soft)}
|
||||
.inc-row__l{min-width:0}
|
||||
.inc-row__l b{font-size:.88rem;font-weight:600;display:block}
|
||||
.inc-row__l span{font-size:.74rem;color:var(--ink-3)}
|
||||
.inc-row__r{display:flex;align-items:center;gap:9px;flex-shrink:0}
|
||||
|
||||
.notes{border-top:1px solid var(--rule);padding-top:14px;display:flex;flex-direction:column;gap:7px}
|
||||
.notes li{font-size:.82rem;color:var(--ink-2);display:flex;gap:10px;list-style:none}
|
||||
.notes li b{color:var(--ink);font-weight:600}
|
||||
.notes .k{font-family:var(--mono);font-size:.66rem;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-3);flex:0 0 76px;padding-top:2px}
|
||||
|
||||
@media (max-width:820px){
|
||||
.app{grid-template-columns:1fr}
|
||||
.side{display:none}
|
||||
.entry{grid-template-columns:1fr}
|
||||
.page{padding:32px 16px 64px}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header class="lede">
|
||||
<p class="cap" style="margin-bottom:10px">Vantage · status pages · mockup for approval</p>
|
||||
<h1>Two screens: what the public sees, and what the operator edits</h1>
|
||||
<p>Drawn with the real <code style="font-family:var(--mono);font-size:.85em">web/</code> dark tokens and the existing sidebar idioms, so what gets approved here is what gets built. The public page is shown mid-incident rather than all-green, because that is the state it exists for.</p>
|
||||
</header>
|
||||
|
||||
<!-- ================= PUBLIC ================= -->
|
||||
<section class="board">
|
||||
<div class="board__head">
|
||||
<p class="cap">1 · Public status page</p>
|
||||
<p class="board__route">web/app/status/[pageId]/page.tsx · no auth, no sidebar</p>
|
||||
</div>
|
||||
|
||||
<div class="frame">
|
||||
<div class="addr">
|
||||
<span class="addr__dots"><i></i><i></i><i></i></span>
|
||||
<span class="addr__url">https://acme.vantage.example.com<b>/status/api</b></span>
|
||||
<span class="addr__tag">signed out</span>
|
||||
</div>
|
||||
|
||||
<div class="pub">
|
||||
<div class="pub__inner">
|
||||
|
||||
<div class="pub__head">
|
||||
<div class="mark">AC</div>
|
||||
<div>
|
||||
<h2>Acme Platform Status</h2>
|
||||
<p>Live availability for the Acme API and dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overall overall--down">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.4"/><path d="M8 4.8v3.6M8 11.1h.01" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Service disruption
|
||||
</div>
|
||||
|
||||
<div class="banner">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.6" aria-hidden="true" style="margin-top:2px">
|
||||
<circle cx="8" cy="8" r="6.4"/><path d="M8 7.4v3.8M8 5.1h.01" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span><b>Europe region only.</b> US and APAC are unaffected. Follow this page for updates.</span>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Active</p>
|
||||
<div class="card">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Elevated error rates on database writes</p>
|
||||
<p class="inc__meta">Started 24 Aug 2026, 09:12 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--mon">monitoring</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Primary database, Public API</p>
|
||||
<div class="timeline">
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">monitoring</span><span class="tl__at">11:40 UTC</span></div>
|
||||
<p class="tl__body">Failover completed. Write latency is back to normal and we are watching for recurrence before calling this resolved.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">identified</span><span class="tl__at">09:48 UTC</span></div>
|
||||
<p class="tl__body">A failing disk on the primary database node is causing write timeouts. Failover to the standby node is in progress.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tl__head"><span class="tl__st">investigating</span><span class="tl__at">09:15 UTC</span></div>
|
||||
<p class="tl__body">We are investigating a rise in write errors affecting the API.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Scheduled maintenance</p>
|
||||
<div class="card">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Object storage capacity upgrade</p>
|
||||
<p class="inc__meta">31 Aug 2026, 02:00 – 04:00 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--sch">scheduled</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Object storage</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">API</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="api" data-state="down">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Public API</span>
|
||||
<span class="state"><i class="dot dot--down"></i>Down</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.81%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="hooks" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Webhook delivery</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.99%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Web</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="dash" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Dashboard</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.97%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Data</p>
|
||||
<div class="card rows">
|
||||
<div class="comp" data-bar="db" data-state="down">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Primary database</span>
|
||||
<span class="state"><i class="dot dot--down"></i>Down</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.62%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="obj" data-state="maint">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Object storage</span>
|
||||
<span class="state"><i class="dot dot--maint"></i>Maintenance</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>99.94%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
<div class="comp" data-bar="new" data-state="up">
|
||||
<div class="comp__top">
|
||||
<span class="comp__name">Search index</span>
|
||||
<span class="state"><i class="dot dot--up"></i>Operational</span>
|
||||
</div>
|
||||
<div class="bar"></div>
|
||||
<div class="scale"><span>90 days ago</span><span><b>100.00%</b> uptime</span><span>Today</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="cap">Past incidents</p>
|
||||
<div class="card rows">
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Public API unavailable</p>
|
||||
<p class="inc__meta">2 Aug 2026, 14:02 UTC — resolved 14:19 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--res">resolved</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Public API</p>
|
||||
</article>
|
||||
<article class="inc">
|
||||
<div class="inc__top">
|
||||
<div>
|
||||
<p class="inc__title">Slow dashboard loads in Europe</p>
|
||||
<p class="inc__meta">17 Jul 2026, 08:30 UTC — resolved 10:05 UTC</p>
|
||||
</div>
|
||||
<span class="pill pill--res">resolved</span>
|
||||
</div>
|
||||
<p class="inc__affects">Affects Dashboard</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pub__foot">Updated 24 Aug 2026, 11:58 UTC · refreshes every 60 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li><span class="k">Redacted</span><span>No target URL, host, port or failure text anywhere on this page. <b>Search index</b> shows the no-data tail as grey cells rather than claiming 100% for days before it existed.</span></li>
|
||||
<li><span class="k">Maintenance</span><span><b>Object storage</b> reads as Maintenance, not Down — but its uptime figure is untouched. The window changes how it is drawn, never what the numbers say.</span></li>
|
||||
<li><span class="k">Colour</span><span>Every state carries a word and a shape as well as a hue. The page is readable with colour vision differences and in greyscale print.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ================= EDITOR ================= -->
|
||||
<section class="board">
|
||||
<div class="board__head">
|
||||
<p class="cap">2 · Status page editor</p>
|
||||
<p class="board__route">web/app/(app)/status-pages/[pageId]/page.tsx · owner or admin</p>
|
||||
</div>
|
||||
|
||||
<div class="frame">
|
||||
<div class="app">
|
||||
<aside class="side">
|
||||
<div class="side__brand">
|
||||
<div class="mark">V</div>
|
||||
<div>
|
||||
<b>Vantage</b>
|
||||
<span class="cap">Acme Ltd</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="side__nav">
|
||||
<div class="navgrp">
|
||||
<p class="cap">Fleet</p>
|
||||
<ul>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="4" rx="1"/><rect x="2" y="9" width="12" height="4" rx="1"/></svg>Servers</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2.5" y="2.5" width="11" height="11" rx="1.5"/><path d="M6 6h4v4H6z"/></svg>Workloads</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1.5 8.5h3l2-4 3 7 2-3h3"/></svg>Monitors</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navgrp">
|
||||
<p class="cap">Access</p>
|
||||
<ul>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="5.5" cy="8" r="3"/><path d="M8.5 8h6M12 8v2.5"/></svg>SSH Keys</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="7" width="10" height="6.5" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 015 0v2"/></svg>Secrets</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navgrp">
|
||||
<p class="cap">Instance</p>
|
||||
<ul>
|
||||
<li><a href="#" class="on"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M4.5 10.5v-2M8 10.5v-4M11.5 10.5v-3" stroke-linecap="round"/></svg>Status Pages</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3h10v10H3z"/><path d="M5.5 6.5h5M5.5 9.5h3"/></svg>Audit Log</a></li>
|
||||
<li><a href="#"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.8v1.6M8 12.6v1.6M14.2 8h-1.6M3.4 8H1.8"/></svg>Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<a class="back" href="#">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M9.5 3.5L5 8l4.5 4.5"/></svg>
|
||||
All status pages
|
||||
</a>
|
||||
|
||||
<div class="phead">
|
||||
<div>
|
||||
<h2>Acme Platform Status</h2>
|
||||
<div class="record">
|
||||
<code>acme.vantage.example.com/status/api</code>
|
||||
<button class="copy" type="button">copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<a class="btn" href="#">
|
||||
<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M6.5 3.5h6v6M12.5 3.5L7 9"/><path d="M11 10.5v2h-8v-8h2"/></svg>
|
||||
View page
|
||||
</a>
|
||||
<button class="btn btn--p" type="button">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Details</h3>
|
||||
<p>What visitors see at the top of the page.</p>
|
||||
</div>
|
||||
<span class="pill pill--live">published</span>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
<div class="toggle">
|
||||
<div>
|
||||
<b>Published</b>
|
||||
<p>Anyone with the link can read this page. Unpublished pages return not found, so you can compose before announcing.</p>
|
||||
</div>
|
||||
<button class="sw" type="button" role="switch" aria-checked="true" aria-label="Published"></button>
|
||||
</div>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label for="f-title">Title</label>
|
||||
<input class="in" id="f-title" value="Acme Platform Status">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-id">Page address</label>
|
||||
<input class="in in--mono" id="f-id" value="api" disabled>
|
||||
<span class="hint">Fixed once created — the link is already out there.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-desc">Description</label>
|
||||
<input class="in" id="f-desc" value="Live availability for the Acme API and dashboard.">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-logo">Logo URL</label>
|
||||
<input class="in in--mono" id="f-logo" placeholder="https://acme.example.com/logo.svg">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="f-ban">Notice</label>
|
||||
<input class="in" id="f-ban" value="Europe region only. US and APAC are unaffected. Follow this page for updates.">
|
||||
<span class="hint">Shown above everything else. Clear it to remove the notice.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Components</h3>
|
||||
<p>Monitors grouped for the public page. Grouping here is separate from the groups on Monitors.</p>
|
||||
</div>
|
||||
<button class="btn" type="button">Add section</button>
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect__head">
|
||||
<input class="in" value="API" aria-label="Section name">
|
||||
<button class="rm" type="button">remove section</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>prod-api-eu-health</b>
|
||||
<span>http · every 30s</span>
|
||||
</div>
|
||||
<input class="in" value="Public API" aria-label="Public name for prod-api-eu-health">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>hooks-dispatch-probe</b>
|
||||
<span>http · every 60s</span>
|
||||
</div>
|
||||
<input class="in" value="Webhook delivery" aria-label="Public name for hooks-dispatch-probe">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="adds"><button class="btn" type="button">Add monitor</button></div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect__head">
|
||||
<input class="in" value="Data" aria-label="Section name">
|
||||
<button class="rm" type="button">remove section</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>pg-primary-10-0-0-5</b>
|
||||
<span>tcp · every 30s</span>
|
||||
</div>
|
||||
<input class="in" value="Primary database" aria-label="Public name for pg-primary-10-0-0-5">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="entry">
|
||||
<div class="entry__mon">
|
||||
<b>minio-gw</b>
|
||||
<span>http · every 60s</span>
|
||||
</div>
|
||||
<input class="in" placeholder="minio-gw" aria-label="Public name for minio-gw">
|
||||
<button class="rm" type="button">remove</button>
|
||||
</div>
|
||||
<div class="adds"><button class="btn" type="button">Add monitor</button></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<div>
|
||||
<h3>Incidents</h3>
|
||||
<p>Written by you. Outages Vantage detects appear on the page automatically.</p>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn" type="button">Schedule maintenance</button>
|
||||
<button class="btn btn--p" type="button">Open incident</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Elevated error rates on database writes</b>
|
||||
<span>Opened 09:12 UTC · 3 updates · affects Primary database, Public API</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--mon">monitoring</span>
|
||||
<button class="btn" type="button">Post update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Object storage capacity upgrade</b>
|
||||
<span>31 Aug, 02:00–04:00 UTC · affects Object storage</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--sch">scheduled</span>
|
||||
<button class="btn" type="button">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inc-row">
|
||||
<div class="inc-row__l">
|
||||
<b>Slow dashboard loads in Europe</b>
|
||||
<span>17 Jul · resolved after 1h 35m · affects Dashboard</span>
|
||||
</div>
|
||||
<div class="inc-row__r">
|
||||
<span class="pill pill--res">resolved</span>
|
||||
<button class="btn" type="button">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li><span class="k">Naming</span><span>The monitor's own identifier stays visible on the left; the <b>public name</b> is a separate field beside it. An empty field falls back to the identifier, which the placeholder shows — so publishing an internal name is always a visible choice.</span></li>
|
||||
<li><span class="k">Address</span><span>The page address is fixed after creation and the record line carries the whole URL, click to copy. It is what gets pasted into a support article.</span></li>
|
||||
<li><span class="k">Copy</span><span>Buttons name the outcome: <b>Open incident</b>, <b>Post update</b>, <b>Schedule maintenance</b> — the same words the public timeline then shows.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 90 daily cells per component. Seeded rather than random so the mockup is
|
||||
// stable between reloads and reviewers are looking at the same picture.
|
||||
const PATTERNS = {
|
||||
api: { downs: [2, 22], maint: [], noData: 0 },
|
||||
hooks: { downs: [], maint: [], noData: 0 },
|
||||
dash: { downs: [38], maint: [], noData: 0 },
|
||||
db: { downs: [0, 1, 12, 13, 47], maint: [], noData: 0 },
|
||||
obj: { downs: [61], maint: [0], noData: 0 },
|
||||
new: { downs: [], maint: [], noData: 61 }
|
||||
};
|
||||
|
||||
document.querySelectorAll(".comp").forEach((comp) => {
|
||||
const p = PATTERNS[comp.dataset.bar];
|
||||
const bar = comp.querySelector(".bar");
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const cell = document.createElement("span");
|
||||
let cls = "up";
|
||||
if (i >= 90 - p.noData) cls = "none";
|
||||
else if (p.maint.includes(i)) cls = "maint";
|
||||
else if (p.downs.includes(i)) cls = "down";
|
||||
cell.className = cls;
|
||||
cell.title = cls === "none" ? "no data" : cls === "maint" ? "maintenance" : cls === "down" ? "outage" : "operational";
|
||||
frag.appendChild(cell);
|
||||
}
|
||||
bar.appendChild(frag);
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
# Public status pages
|
||||
|
||||
Date: 2026-08-24
|
||||
|
||||
## Goal
|
||||
|
||||
Let an operator publish one or more public status pages from a Vantage
|
||||
instance, at `<slug>.vantage.<tld>/status/<page-id>`, showing the state of any
|
||||
monitors they choose, plus incidents and maintenance windows they author by
|
||||
hand. The pages are completely public: no session, no token, no login.
|
||||
|
||||
Out of scope, deliberately:
|
||||
|
||||
- **Custom domains** (`status.customer.com`). Needs certificate provisioning and
|
||||
a host-to-page lookup that bypasses `hostSlug` entirely. Its own sub-project.
|
||||
- **Per-page themes.** `web/` is locked dark by design and a public page is not
|
||||
the place to break that.
|
||||
- **Subscriber notifications.** Email or webhook on incident updates is a
|
||||
notification subsystem, and one already exists for monitors; wiring the two
|
||||
together is a separate decision.
|
||||
- **SLA reporting.** Uptime percentages are shown; contractual SLA calculation
|
||||
with credits and exclusions is a different product.
|
||||
|
||||
## Current state
|
||||
|
||||
Everything needed to draw a status page already exists and is already scoped by
|
||||
instance:
|
||||
|
||||
| Data | Where |
|
||||
| --- | --- |
|
||||
| Monitor identity and live state | `models.Monitor`, `Monitor.State` |
|
||||
| Outage records | `models.Incident`, opened when a monitor flips down |
|
||||
| Hourly uptime history | `models.Rollup` (`monitor_rollups`) |
|
||||
| Sub-hour history | `models.MonitorSample`, TTL-expired |
|
||||
| Instance from hostname | `auth.InstanceFromHost`, 60s cached |
|
||||
|
||||
Three things do not exist: any concept of a page, any operator-authored
|
||||
incident, and any unauthenticated read path. The third is the constraint that
|
||||
shapes the rest — every route under `/api` carries `auth.Middleware`,
|
||||
`RequireScopes`, `RateLimitTokens` and `RequireActiveLicense` by virtue of where
|
||||
it is mounted, and `AssertScopeMapComplete` fails boot on an `/api` route with
|
||||
no scope entry.
|
||||
|
||||
## Approach
|
||||
|
||||
Two new collections hold the page and the authored incidents. A single
|
||||
assembly function reads them alongside the existing monitor data and emits a
|
||||
purpose-built public struct. The public route is mounted outside `/api`, is
|
||||
cached in Redis, and is rate limited per client address.
|
||||
|
||||
The redaction boundary is the assembly function, and it is the security
|
||||
property of this whole feature.
|
||||
|
||||
## Data model
|
||||
|
||||
Both collections carry `instance_id` and both must be added to
|
||||
`services.ScopedCollections`, or their rows outlive a deleted instance.
|
||||
|
||||
### `status_pages`
|
||||
|
||||
One document per page. It is read whole, always, so its structure is embedded
|
||||
rather than joined: one page is one Mongo read is one cache fill.
|
||||
|
||||
```
|
||||
_id, instance_id
|
||||
page_id // operator-chosen slug, [a-z0-9-], 3-40 chars
|
||||
title, description, logo_url
|
||||
published bool
|
||||
banner { enabled, level, text }
|
||||
sections [ { name, entries: [ { monitor_id, display_name } ] } ]
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
Unique index on `(instance_id, page_id)`. The slug is operator-chosen rather
|
||||
than random because it is a URL handed to customers and printed on support
|
||||
pages; a random identifier would be unguessable and unmemorable in equal
|
||||
measure.
|
||||
|
||||
`published` exists so a page can be composed before anyone sees it. An
|
||||
unpublished page answers the same 404 as a page that does not exist — a
|
||||
distinct 403 would confirm it exists.
|
||||
|
||||
Sections are page-local and unrelated to `Monitor.Group`, which is a display
|
||||
label on the authenticated monitors list. One monitor may appear under "API" on
|
||||
the customer page and "Edge" on the partner page, under two different display
|
||||
names. That is the point of the override: a monitor's internal name is often
|
||||
not a name you want published.
|
||||
|
||||
The banner is three fields on the page rather than a collection, because it is
|
||||
one string with no lifecycle.
|
||||
|
||||
### `status_incidents`
|
||||
|
||||
Manual incidents and maintenance windows share one shape, because they share a
|
||||
timeline, an impact and a set of affected components; splitting them into two
|
||||
collections would duplicate all three.
|
||||
|
||||
```
|
||||
_id, instance_id, incident_id
|
||||
page_ids []string // which pages show it
|
||||
kind "incident" | "maintenance"
|
||||
title
|
||||
impact // none | minor | major | critical
|
||||
affected_monitors []string // monitor_ids
|
||||
status // incident: investigating | identified | monitoring | resolved
|
||||
// maintenance: scheduled | in_progress | completed
|
||||
scheduled_start, scheduled_end // maintenance only
|
||||
updates [ { at, status, body, author } ]
|
||||
started_at, resolved_at, created_at, updated_at
|
||||
```
|
||||
|
||||
Updates are embedded for the same reason sections are: they are few, and they
|
||||
are never read apart from their incident.
|
||||
|
||||
`page_ids` is explicit rather than derived from `affected_monitors`. Deriving it
|
||||
would be less to fill in, but adding a monitor to a page later would
|
||||
retroactively republish old incidents to a new audience. An operator publishing
|
||||
to customers chooses that audience.
|
||||
|
||||
### Auto-incidents are derived, never copied
|
||||
|
||||
The existing `incidents` collection remains the only writer for
|
||||
monitor-detected outages. The public snapshot derives them at assembly time:
|
||||
filter to the monitors on the page, last 90 days, render as display name, start,
|
||||
end and duration.
|
||||
|
||||
`Incident.Cause` is dropped. It is where `dial tcp 10.0.0.5:5432: connect
|
||||
refused` lives.
|
||||
|
||||
Copying auto-incidents into `status_incidents` would be a second writer for the
|
||||
same fact, arriving by a different route with its own opportunity to disagree —
|
||||
the same argument that keeps `RefreshWorkloadsCmd` from returning workloads
|
||||
inline.
|
||||
|
||||
### Maintenance does not rewrite uptime
|
||||
|
||||
During a maintenance window, affected components render as "under maintenance"
|
||||
rather than down. The uptime percentage and the history bar still come from the
|
||||
rollups, unmodified.
|
||||
|
||||
Rollups are the durable record. Bending them so a page looks better is a lie
|
||||
pointed the other way, and the operator who later asks "what was our actual
|
||||
availability" gets an answer that was edited for publication.
|
||||
|
||||
## The redaction boundary
|
||||
|
||||
`services.BuildStatusSnapshot(instanceID, pageID)` is the only function that
|
||||
reads `monitors`, `incidents`, `monitor_rollups` and `status_incidents` on
|
||||
behalf of an anonymous caller, and it emits a purpose-built struct.
|
||||
|
||||
**`models.Monitor` is never marshalled to a public caller.** Target URL, host,
|
||||
port, method, keyword, `state.message`, `state.cert_expiry_at` and
|
||||
`channel_ids` all stay behind the boundary. A field added to `Monitor` next year
|
||||
is private by default rather than published by accident.
|
||||
|
||||
What the snapshot contains, per entry: display name, current status, uptime
|
||||
percentage over the last 90 days, and a 90-day history bar of one cell per day.
|
||||
A cell is up, down, under maintenance, or no-data — `no-data` for days before
|
||||
the monitor existed, which is a distinct thing from a day it was down. No
|
||||
latency, no addresses, no failure text.
|
||||
|
||||
## Public read path
|
||||
|
||||
```
|
||||
GET /public/status/:pageId
|
||||
```
|
||||
|
||||
Mounted on the gin root, not under `apiGroup`. Putting it under `/api` would
|
||||
require exempting it from authentication, scope enforcement, token rate
|
||||
limiting and the licence gate — four holes, each one something a later change
|
||||
can widen. Outside `/api` it needs none of them.
|
||||
|
||||
The instance is resolved from the request host through `auth.InstanceFromHost`.
|
||||
A host with no instance label, an unknown slug, an unknown page and an
|
||||
unpublished page all answer **404**, identically.
|
||||
|
||||
### The feature gate answers 200, not 403
|
||||
|
||||
Status pages are gated by a new `license.FeatureStatusPages = "status_pages"`,
|
||||
on both the authoring routes and the public read.
|
||||
|
||||
The public side checks inline rather than through `RequireFeature`, which
|
||||
aborts with a 403 JSON body. A public page needs to render an explanation:
|
||||
|
||||
```json
|
||||
{ "available": false, "reason": "feature_unavailable", "title": "Acme Status" }
|
||||
```
|
||||
|
||||
`reason` is `feature_unavailable` when the tier does not include the feature and
|
||||
`licence_inactive` when the licence has lapsed. The title is included so the
|
||||
page does not look broken; nothing else is.
|
||||
|
||||
**This is not only a server change.** The feature must be added to admin's
|
||||
`plans` rows per `(deployment, tier)`, or every instance reads it as absent and
|
||||
the feature ships dark.
|
||||
|
||||
### Cache
|
||||
|
||||
Redis key `vantage:status:<instance_id>:<page_id>` holds the assembled JSON with
|
||||
a 30-second TTL. N visitors cost one Mongo read regardless of traffic.
|
||||
|
||||
Authoring writes delete the key, so an operator posting an incident update sees
|
||||
it immediately rather than wondering for half a minute whether it saved.
|
||||
|
||||
Redis rather than Next ISR because with `replicaCount > 1` each `web` pod would
|
||||
cache separately and two visitors would see different states during an incident.
|
||||
|
||||
### Rate limit
|
||||
|
||||
Per client address, one-minute fixed window, 120 requests, 429 with
|
||||
`Retry-After` — the same shape as `RateLimitTokens`, including its most
|
||||
important property: **when Redis is unavailable, allow rather than deny.** A
|
||||
status page must survive the outage it exists to report.
|
||||
|
||||
### Trusted proxies
|
||||
|
||||
Nothing calls `r.SetTrustedProxies`, so gin trusts every proxy and
|
||||
`c.ClientIP()` takes `X-Forwarded-For` verbatim. That is spoofable per request,
|
||||
which makes a per-address limiter decorative.
|
||||
|
||||
This has not mattered so far because `ClientIP()` is only used for audit
|
||||
strings. It matters now, so this work adds a trusted-proxy configuration and
|
||||
sets it at boot. Without it the rate limit is theatre.
|
||||
|
||||
## Authoring API
|
||||
|
||||
Under `/api`, owner or admin, behind `RequireFeature("status_pages")`, every
|
||||
mutation audited:
|
||||
|
||||
```
|
||||
GET,POST /status-pages
|
||||
GET,PUT,DELETE /status-pages/:pageId
|
||||
GET,POST /status-pages/:pageId/incidents
|
||||
PUT,DELETE /status-pages/:pageId/incidents/:incidentId
|
||||
POST /status-pages/:pageId/incidents/:incidentId/updates
|
||||
```
|
||||
|
||||
This adds a ninth scope resource, `status:read` and `status:write`. The entries
|
||||
are required, not optional: `AssertScopeMapComplete` fails boot on an `/api`
|
||||
route with no scope entry, which is exactly the safeguard working.
|
||||
|
||||
Handlers need `@…` annotations and `openapi.json` must be regenerated and
|
||||
committed — `server-deploy.yml` runs `git diff --exit-code` against the
|
||||
committed copy, so a handler whose annotation drifted fails CI.
|
||||
|
||||
## Frontend
|
||||
|
||||
`web/app/status/[pageId]/page.tsx`, **outside the `(app)` route group**, so it
|
||||
inherits no sidebar, no session fetch and no auth redirect. Server-rendered
|
||||
against the Go endpoint, with a client refresh every 60 seconds.
|
||||
|
||||
`web/next.config.ts` gains a `/public/:path*` rewrite so that client refresh
|
||||
reaches the server.
|
||||
|
||||
The page stays dark, like the rest of `web/`, and carries no hex values — the
|
||||
existing token palette covers every state it needs.
|
||||
|
||||
Authoring UI at `/status-pages` inside `(app)`, in the **Instance** sidebar
|
||||
group. It is `adminOnly`, and since the whole group is, a member sees the group
|
||||
disappear entirely rather than a labelled section with nothing under it.
|
||||
|
||||
## Testing
|
||||
|
||||
The snapshot tests are the ones that matter, because they are the redaction
|
||||
boundary made executable:
|
||||
|
||||
- `BuildStatusSnapshot` output contains no target URL or host, no
|
||||
`state.message`, no `incident.cause`, no `channel_ids`, no latency.
|
||||
- A monitor on no page never appears in any page's snapshot.
|
||||
- An unpublished page and an unknown page both 404.
|
||||
- Feature absent and licence inactive both return 200 with `available: false`
|
||||
and the matching `reason`.
|
||||
- A cache hit performs no Mongo read; an authoring write invalidates the key.
|
||||
- Slug validation: character set, length, uniqueness within an instance.
|
||||
- Maintenance window renders the component as under maintenance while leaving
|
||||
the uptime percentage untouched.
|
||||
|
||||
## Migration and rollout
|
||||
|
||||
No migration is needed — both collections are new and absent means empty. Index
|
||||
builders follow the `EnsureWorkflowIndexes` precedent and warn rather than being
|
||||
fatal: a missing index on a small collection degrades to a scan, which is no
|
||||
reason to refuse to serve the fleet.
|
||||
|
||||
The feature ships dark until the `status_pages` feature is added to the plan
|
||||
rows in admin.
|
||||
@@ -0,0 +1,330 @@
|
||||
# Control plane backup and restore
|
||||
|
||||
Date: 2026-09-07
|
||||
Status: approved, ready for implementation planning
|
||||
|
||||
## Problem
|
||||
|
||||
Vantage has no backup story. A self-hosted deployment holds its entire state in
|
||||
MongoDB and encrypts the sensitive half of it — SSH private keys, key
|
||||
passphrases, vault secrets, OIDC client secrets, RDP and VNC credentials — with
|
||||
AES-256-GCM under a single 32-byte key supplied as the `KEY_ENCRYPTION_KEY`
|
||||
environment variable.
|
||||
|
||||
That key is a bare value. It carries no identifier, is not wrapped, and is not
|
||||
recorded anywhere alongside the data it protects. Restoring a database without
|
||||
it produces a control plane whose every secret is permanently unreadable, and
|
||||
nothing in the product tells an operator this before it happens.
|
||||
|
||||
`mongodump` exists and operators can use it, but it says nothing about the
|
||||
encryption key, so the most common way to lose everything is to hold a perfectly
|
||||
good database dump and no key.
|
||||
|
||||
## Goal
|
||||
|
||||
A standalone command-line tool that backs up and restores a whole Vantage
|
||||
deployment, and that makes the key relationship impossible to get wrong by
|
||||
accident.
|
||||
|
||||
Explicitly not a goal: point-in-time recovery, incremental backups, built-in
|
||||
storage backends, encryption of the archive itself, per-tenant export, and
|
||||
backups scheduled from inside the server. Each is a separate decision and
|
||||
several are better served by tools the operator already has.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope of a backup
|
||||
|
||||
One backup covers one MongoDB database: every collection in it, whether or not
|
||||
that collection is tenant-scoped. A deployment-level disaster recovery tool that
|
||||
skipped `migrations` or `vulndb_meta` would restore a database the server
|
||||
refuses to boot against.
|
||||
|
||||
Collections are enumerated live with `ListCollectionNames` rather than read from
|
||||
a hardcoded list. This is the opposite choice to `services.ScopedCollections`,
|
||||
and deliberately so: that list can afford to be hand-maintained because
|
||||
`AssertNoScopedCollectionMissed` fails boot when it drifts. A backup tool has no
|
||||
such assertion available, so a second hand-maintained registry would drift
|
||||
silently and the first symptom would be a restore missing a collection nobody
|
||||
noticed was added.
|
||||
|
||||
`--exclude` accepts collection names for the volume-heavy ones —
|
||||
`workflow_log_lines`, `monitor_samples`, `audit_logs`. Whatever is excluded is
|
||||
recorded in the manifest, so an archive can never claim to be complete when it
|
||||
is not.
|
||||
|
||||
Redis is not backed up. It holds sessions only; losing it logs everyone out and
|
||||
nothing else, which is already the documented behaviour. The restore output says
|
||||
so explicitly rather than leaving an operator to wonder.
|
||||
|
||||
### Where the code lives
|
||||
|
||||
Two units.
|
||||
|
||||
`shared/backup/` holds the logic: dump, restore, manifest construction, archive
|
||||
reading and writing, and key fingerprinting. It depends on the MongoDB driver
|
||||
and the standard library, and on no CLI framework. Keeping it in `shared/` and
|
||||
free of cobra is what lets `server` import it later if backups scheduled from
|
||||
inside the control plane are ever built, without pulling a command-line parser
|
||||
into the server binary.
|
||||
|
||||
`vantagectl/` is a new module in `go.work`, importing `shared`. It holds the
|
||||
cobra command tree and nothing else. A separate module rather than a package
|
||||
under `shared/` because adding cobra to `shared/go.mod` would put cobra and
|
||||
pflag into the module graph of `server`, `admin` and `sitesvc`, none of which
|
||||
use them. Binaries are unaffected — Go links only what is imported — but three
|
||||
`go.sum` files would grow and three CI builds would fetch a dependency they do
|
||||
not need. `agent/` is already a separate module for the same reason.
|
||||
|
||||
The tool imports nothing from `server/`. No `db.Col()`, no `services`, no config
|
||||
loader, and it never dials the REST or gRPC API. It needs only network reach to
|
||||
MongoDB, a database name, and `KEY_ENCRYPTION_KEY` in its own environment. This
|
||||
is what lets it run against a control plane that is down, half-migrated, or was
|
||||
deleted an hour ago — which is the only condition under which anyone runs a
|
||||
restore.
|
||||
|
||||
### Dump implementation
|
||||
|
||||
The dump is written against the MongoDB driver, not by shelling out to
|
||||
`mongodump`.
|
||||
|
||||
Two reasons. `server`'s runtime image is `scratch` and carries no shell and no
|
||||
mongo tools, so a wrapper would depend on a matching `mongodump` version being
|
||||
installed on whatever host runs the tool. And the manifest must be written by
|
||||
the same process that read the documents, or the fingerprint and per-collection
|
||||
checksums are claims about data the writer never saw.
|
||||
|
||||
The cost is that BSON round-tripping is ours to get right. Documents are written
|
||||
as raw BSON exactly as the driver returns them, without an intermediate map, so
|
||||
`ObjectId`, `Decimal128`, `DateTime`, binary subtypes and nulls survive
|
||||
unchanged. A round-trip test asserting byte-equal BSON is the guard.
|
||||
|
||||
### Archive format
|
||||
|
||||
A gzipped tar named `vantage-backup-<db>-<RFC3339>.tar.gz`:
|
||||
|
||||
```
|
||||
manifest.json
|
||||
collections/<name>.bson concatenated raw BSON documents
|
||||
indexes/<name>.json index specifications
|
||||
```
|
||||
|
||||
`manifest.json` carries:
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `format_version` | Currently `1`. Restore refuses an unknown version rather than guessing at it |
|
||||
| `created_at` | RFC3339, UTC |
|
||||
| `vantage_version` | Build stamp of the tool that wrote the archive |
|
||||
| `hostname` | Provenance; which machine produced this |
|
||||
| `mongo_db` | Source database name |
|
||||
| `mongo_server_version` | Restore warns on a major version gap |
|
||||
| `key_fingerprint` | `sha256` of the raw 32 key bytes, hex, or `null`. Never the key |
|
||||
| `collections[]` | Per collection: name, document count, uncompressed bytes, `sha256` of the `.bson` member |
|
||||
| `excluded[]` | Collection names passed to `--exclude` |
|
||||
|
||||
Per-collection checksums mean a truncated or corrupted archive is detected
|
||||
before a single document is written, rather than halfway through a restore.
|
||||
|
||||
### Key custody
|
||||
|
||||
The key never enters the archive. The archive is exactly as sensitive as a
|
||||
`mongodump` of the same database, and no more.
|
||||
|
||||
What the archive carries is `sha256` of the raw key bytes. A hash of the key
|
||||
proves identity without being a hint at the value, which is what allows an
|
||||
operator to answer "will this archive restore into this deployment" without
|
||||
holding both in front of them.
|
||||
|
||||
Backup refuses to run when `KEY_ENCRYPTION_KEY` is unset or malformed. An
|
||||
archive full of ciphertext whose key was never recorded is worse than no archive
|
||||
at all, because it looks like a backup. `--allow-no-key` exists for a deployment
|
||||
that genuinely stores no encrypted material; it stamps `key_fingerprint: null`,
|
||||
which restore then reports loudly rather than treating as a match.
|
||||
|
||||
Restore compares the archive's fingerprint against the key in the current
|
||||
environment:
|
||||
|
||||
- Fingerprints match: proceed.
|
||||
- Fingerprints differ: refuse, printing both.
|
||||
- Archive has a fingerprint, environment has no key: refuse.
|
||||
- `--ignore-key-mismatch`: proceed, having first printed exactly which
|
||||
collections hold ciphertext that will be undecryptable — `keys`, `secrets`,
|
||||
`auth_providers`, `console_sessions`, `settings`.
|
||||
|
||||
### Restore semantics
|
||||
|
||||
The order is fixed:
|
||||
|
||||
1. Read `manifest.json` and check `format_version`.
|
||||
2. Verify every archive member against its manifest checksum. Nothing is written
|
||||
before this passes.
|
||||
3. Apply the fingerprint rules above.
|
||||
4. Inspect the target: `ListCollectionNames` and document counts. A non-empty
|
||||
database is refused, printing what was found. `--force` proceeds.
|
||||
5. Per collection: under `--force`, drop it first; then bulk-insert in batches
|
||||
of 1000 with `ordered=false`.
|
||||
6. Replay index specifications from `indexes/<name>.json`, skipping `_id_`.
|
||||
7. Print a summary: collection, documents restored, indexes created.
|
||||
|
||||
Restore is not idempotent, and says so. A second run without `--force` is
|
||||
refused because step 4 now finds data. A restore interrupted during step 5
|
||||
leaves a partial database that the next run refuses to touch — correct, because
|
||||
the alternative is a silent merge. There are no merge or upsert semantics at
|
||||
all: merging two control planes reconciles nothing and produces a fleet that
|
||||
half works, and upserting by `_id` resurrects rows deleted since the backup,
|
||||
which for revoked keys and deleted users is a security regression wearing the
|
||||
costume of a convenience.
|
||||
|
||||
Index replay is fatal per collection when a unique index fails to build, and a
|
||||
warning when a non-unique one does. A unique index that cannot be created means
|
||||
the restored data violates it, and the unique indexes here — `(instance_id,
|
||||
email)`, instance slug, settings instance, the ESO token hash — are
|
||||
tenant-isolation properties rather than optimisations. The failure names the
|
||||
offending index.
|
||||
|
||||
### Destructive confirmation
|
||||
|
||||
Restore under `--force` requires a typed confirmation when stdin is a TTY.
|
||||
|
||||
When stdin is not a TTY — a Kubernetes Job, a CI step, a cron entry — the
|
||||
confirmation comes from `--confirm-db <name>`, whose value must equal the
|
||||
resolved target database name or restore refuses. Naming the database in the
|
||||
argument means a copy-pasted restore command carries its intended target with
|
||||
it and cannot destroy a different one.
|
||||
|
||||
A dynamic flag name containing the database name was considered and rejected:
|
||||
cobra registers flags before parsing, and the target database is not known at
|
||||
registration time.
|
||||
|
||||
### Command surface
|
||||
|
||||
```
|
||||
vantagectl root; prints help
|
||||
├── backup --out DIR|- --exclude a,b --allow-no-key
|
||||
├── restore ARCHIVE --force --confirm-db NAME --ignore-key-mismatch
|
||||
├── inspect ARCHIVE
|
||||
└── verify ARCHIVE
|
||||
```
|
||||
|
||||
Persistent flags on the root command, so every subcommand accepts them and they
|
||||
are documented once: `--mongo-uri` (env `MONGO_URI`) and `--db` (env `MONGO_DB`,
|
||||
falling back to the URI path). There is no `--log-level`: the tool's entire
|
||||
output is what it is telling the operator, and a level that could hide a key
|
||||
warning is worth not having.
|
||||
|
||||
Environment fallback is wired with an explicit `Changed` check on each flag
|
||||
rather than through viper. Viper is a configuration-file and remote-config
|
||||
system; this tool reads no configuration file, and pulling it in to call
|
||||
`os.Getenv` would make the largest dependency in the binary the one doing the
|
||||
smallest job.
|
||||
|
||||
`inspect` prints the manifest — when the archive was made, by what version,
|
||||
which collections it holds, how many documents, what was excluded, and the key
|
||||
fingerprint — and contacts no database. It is what an operator runs to find out
|
||||
whether an archive they have found is worth anything.
|
||||
|
||||
`verify` adds a live check: whether the archive's fingerprint matches the key in
|
||||
the current environment, and — when `--mongo-uri` is given — whether that key
|
||||
actually decrypts the target database. The second half is a probe: read one
|
||||
ciphertext field from `secrets`, `keys` or `auth_providers` and attempt to open
|
||||
it. A fingerprint comparison proves two archives agree; only a probe proves the
|
||||
key in hand opens the data in front of you. `verify` is the command that
|
||||
distinguishes "we have backups" from "we have backups that will restore", and
|
||||
the documentation recommends running it on a schedule.
|
||||
|
||||
The probe needs AES-256-GCM open, which today lives in
|
||||
`server/internal/services/crypto.go` and cannot be imported from another module.
|
||||
Rather than copy it — the exact hazard `CLAUDE.md` names around mirrored token
|
||||
blocks and `web/lib/targets.ts` — the primitives move to a new `shared/cryptobox`
|
||||
package, and `services/crypto.go` becomes a thin delegation that keeps its
|
||||
existing unexported function names and its `KEY_ENCRYPTION_KEY` lookup. One
|
||||
implementation of the cipher, two callers.
|
||||
|
||||
`--out -` streams the tarball to stdout, so piping into `aws s3 cp -`, `restic`
|
||||
or `age` covers storage and archive encryption without the tool growing backends
|
||||
of its own.
|
||||
|
||||
### Distribution
|
||||
|
||||
Three ways to run it, because the deployments that need it run Docker Compose,
|
||||
Kubernetes, or neither.
|
||||
|
||||
**Loose binary.** A new `.gitea/workflows/vantagectl-release.yml`, triggered on
|
||||
`vantagectl/v*` tags, shaped like `agent-release.yml`. Builds `linux/amd64`,
|
||||
`linux/arm64`, `darwin/arm64` and `windows/amd64` with `CGO_ENABLED=0`, writes
|
||||
`checksums.txt`, and creates a Gitea release.
|
||||
|
||||
**Container image.** `vantagectl/Dockerfile` — the repo's convention is a
|
||||
Dockerfile per module built from the repository root, because every Go module
|
||||
depends on `shared` through a replace directive — produces a `scratch`
|
||||
image holding the static binary and an explicitly copied `/tmp`, which the
|
||||
archive is staged in before compression — the same omission that silently
|
||||
disabled `vulnsched` on a scratch image. Pushed by `server-deploy.yml` as an
|
||||
eighth image.
|
||||
|
||||
```bash
|
||||
docker run --rm --network vantage_default \
|
||||
-e MONGO_URI -e MONGO_DB -e KEY_ENCRYPTION_KEY \
|
||||
-v /backups:/out \
|
||||
gitea.hostxtra.co.uk/mrhid6/vantagectl backup --out /out
|
||||
```
|
||||
|
||||
**Kubernetes.** The chart gains `backup.enabled`, defaulting to **false**,
|
||||
rendering a `CronJob` that runs the same image and mounts the existing MongoDB
|
||||
and `KEY_ENCRYPTION_KEY` secrets by reference rather than re-declaring them.
|
||||
Output goes to a PVC named in values. The default is off because a backup with
|
||||
nowhere durable to land is a false sense of safety and the chart cannot know
|
||||
where that is; `NOTES.txt` says so on install.
|
||||
|
||||
Restore in Kubernetes is the same image run as a one-shot `Job`. The chart ships
|
||||
no restore manifest: a restore is an operator decision with a confirmation
|
||||
attached to it, and must never be something a `helm upgrade` can trigger.
|
||||
|
||||
`server-deploy.yml`'s rebuild trigger table gains a `vantagectl` row —
|
||||
`vantagectl/`, `shared/`, `go.work` — which makes `shared/` fan out to four Go
|
||||
images rather than three. That table is already called out in `CLAUDE.md` as a
|
||||
place where a missed entry ships a stale image.
|
||||
|
||||
## Testing
|
||||
|
||||
`shared/backup` is tested against a real MongoDB, via `testcontainers-go` if the
|
||||
module graph tolerates it and otherwise behind a `MONGO_TEST_URI` environment
|
||||
variable that skips when unset.
|
||||
|
||||
Required cases:
|
||||
|
||||
- Round trip: seed one document of every awkward BSON type — `ObjectId`,
|
||||
`Decimal128`, `DateTime`, binary, null, nested arrays — back up, restore into
|
||||
a second database, assert byte-equal BSON.
|
||||
- A single corrupted byte in a `.bson` member causes restore to refuse before
|
||||
writing anything.
|
||||
- Fingerprint mismatch is refused; `--ignore-key-mismatch` proceeds and names
|
||||
the ciphertext-bearing collections.
|
||||
- A non-empty target is refused; `--force` replaces it.
|
||||
- An excluded collection is absent from the archive and named in the manifest.
|
||||
- A unique index that cannot be built aborts the restore, naming the index.
|
||||
|
||||
Fingerprint computation is a pure function and is tested without a database.
|
||||
|
||||
## Documentation
|
||||
|
||||
`docsite/docs/operations/backup-and-restore.md`, covering:
|
||||
|
||||
- What `KEY_ENCRYPTION_KEY` is, that it is not in the backup, and that losing it
|
||||
is unrecoverable. This comes first on the page, not as a note at the bottom.
|
||||
- The three run modes above, each as a command that can be copied.
|
||||
- A restore drill: restore into a scratch database and run `verify`, because an
|
||||
untested backup is a hypothesis.
|
||||
- What is not covered: Redis sessions, the vulnerability database (re-pulled
|
||||
automatically), and agent state on managed servers — agents reconnect on their
|
||||
own and `servers.agent_token_hash` is in the backup, so no re-enrolment is
|
||||
needed.
|
||||
|
||||
`CLAUDE.md` gains a section describing the tool, since a new module, a new
|
||||
image, a new workflow and a new chart toggle are each something that drifts
|
||||
quietly.
|
||||
|
||||
## Open questions
|
||||
|
||||
None. Every decision above was settled during design.
|
||||
@@ -96,8 +96,16 @@ rather than run in a half-prepared state.
|
||||
|
||||
## 4. Put a proxy in front
|
||||
|
||||
Point your reverse proxy at `web` on port `3000` and terminate TLS there. The
|
||||
web app reaches the API internally, so there is no need to publish port `8080`.
|
||||
Terminate TLS at your reverse proxy and route **one hostname to two backends**:
|
||||
|
||||
| Path | Backend |
|
||||
| -------------------------------------------------------------------------------- | ------------- |
|
||||
| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` |
|
||||
| everything else | `web:3000` |
|
||||
|
||||
Both rules are required. The web app forwards nothing to the API, so a proxy
|
||||
that sends the whole hostname to `web:3000` serves the interface and answers
|
||||
`404` to every request it makes — starting with the login form.
|
||||
|
||||
Agents connect to port `9090`. Vantage does not terminate TLS itself, so put
|
||||
that port behind your proxy too, with a certificate valid for the name in
|
||||
|
||||
@@ -28,13 +28,14 @@ entitlement.
|
||||
|
||||
## Features
|
||||
|
||||
Three features are enabled per instance rather than bundled into a tier:
|
||||
Four features are enabled per instance rather than bundled into a tier:
|
||||
|
||||
| Feature | What it enables |
|
||||
| ---------------------- | ------------------------------------------------------------------------------- |
|
||||
| Browser console | The [browser console](../vantage/browser-console.md) |
|
||||
| Single sign-on | [Sign-in through your identity provider](../vantage/settings.md#single-sign-on) |
|
||||
| Vulnerability scanning | [Package vulnerability scanning](../vantage/vulnerabilities.md) |
|
||||
| Status pages | [Public status pages](../vantage/status-pages.md) |
|
||||
|
||||
No tier includes them by default; you enable them on the instances that need
|
||||
them.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
id: backup-and-restore
|
||||
title: Backup and restore
|
||||
sidebar_label: Backup and restore
|
||||
---
|
||||
|
||||
`vantagectl` is a separate command-line tool that backs up and restores the
|
||||
MongoDB database behind a Vantage control plane. It talks to MongoDB directly,
|
||||
never to the Vantage API, so it works against a control plane that is down,
|
||||
half-migrated, or gone — exactly the situation a backup tool has to survive.
|
||||
|
||||
For the store-level overview — what holds what, and why the database alone is
|
||||
not a backup — see [Backups](./backups.md). This page covers the tool.
|
||||
|
||||
:::danger The key comes first
|
||||
Vantage encrypts SSH private keys, key passphrases, vault secrets, SSO client
|
||||
secrets and console credentials with `KEY_ENCRYPTION_KEY`. **It is not in your
|
||||
backup, and it is not recoverable.** A database restored without it is
|
||||
permanently unreadable — not degraded, not partially readable, unreadable.
|
||||
|
||||
Store it wherever you store the credentials you could not rebuild: a password
|
||||
manager, a secrets vault outside this control plane, a piece of paper in a
|
||||
safe. Anywhere but next to the archive.
|
||||
:::
|
||||
|
||||
## What a backup holds
|
||||
|
||||
Every collection in the database, the index definitions each one needs to be
|
||||
useful again, and a SHA-256 **fingerprint** of `KEY_ENCRYPTION_KEY` — never the
|
||||
key itself. The fingerprint is what lets a later `restore` or `verify` tell you
|
||||
that the key you are holding is the wrong one, before it writes a database
|
||||
nobody can read.
|
||||
|
||||
## What it does not hold
|
||||
|
||||
- **Redis sessions.** Everyone signs in again after a restore, which is already
|
||||
true whenever Redis itself restarts.
|
||||
- **The vulnerability database.** It is re-pulled automatically on next boot.
|
||||
- **Agent state on managed servers.** Nothing needs re-enrolling: agents
|
||||
reconnect on their own, because `servers.agent_token_hash` — the thing an
|
||||
agent authenticates with — is itself in the backup.
|
||||
|
||||
:::note Pin the version
|
||||
The image is published on each `vantagectl/v*` release and tagged with that
|
||||
version; `:latest` also moves. Pin a version in anything scheduled. A restore
|
||||
is easier to reason about when you can say which build produced the archive and
|
||||
which one read it back.
|
||||
:::
|
||||
|
||||
## Taking a backup
|
||||
|
||||
The loose binary:
|
||||
|
||||
```bash
|
||||
export MONGO_URI=mongodb://localhost:27017
|
||||
export MONGO_DB=vantage
|
||||
export KEY_ENCRYPTION_KEY=<your 64-char hex key>
|
||||
vantagectl backup --out /backups
|
||||
```
|
||||
|
||||
The container:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-e MONGO_URI=mongodb://mongo:27017 \
|
||||
-e MONGO_DB=vantage \
|
||||
-e KEY_ENCRYPTION_KEY=<your 64-char hex key> \
|
||||
-v /backups:/backups \
|
||||
gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:0.1.0 backup --out /backups
|
||||
```
|
||||
|
||||
Kubernetes, as a scheduled `CronJob` the Helm chart can render for you:
|
||||
|
||||
```yaml
|
||||
backup:
|
||||
enabled: true
|
||||
schedule: "0 2 * * *"
|
||||
image: "gitea.hostxtra.co.uk/mrhid6/vantage/vantagectl:0.1.0"
|
||||
pvcName: "vantage-backups"
|
||||
```
|
||||
|
||||
`backup.enabled` defaults to `false`, and the chart refuses to render if it is
|
||||
turned on without both `backup.image` and `backup.pvcName` — a backup needs a
|
||||
known image and somewhere durable to land, and guessing at either is worse than
|
||||
refusing to start. `backup.exclude` names collections to leave out (recorded in
|
||||
the archive's manifest, so an archive never claims to be complete when it is
|
||||
not), and `backup.successfulJobsHistoryLimit` / `backup.failedJobsHistoryLimit`
|
||||
/ `backup.resources` behave exactly as they do on any other `CronJob`.
|
||||
|
||||
`backup` refuses to run without `KEY_ENCRYPTION_KEY` set in the environment,
|
||||
unless you pass `--allow-no-key` — for a deployment that genuinely stores no
|
||||
encrypted data. Everywhere else, treat the refusal as the tool doing its job.
|
||||
|
||||
## Where to put the archive
|
||||
|
||||
`--out -` streams the tarball to stdout instead of writing a file, and every
|
||||
line of progress output goes to stderr — so piping the archive into something
|
||||
else is always safe, nothing progress-related lands in the stream.
|
||||
|
||||
Into `restic`:
|
||||
|
||||
```bash
|
||||
vantagectl backup --out - | restic backup --stdin --stdin-filename vantage.tar.gz
|
||||
```
|
||||
|
||||
Into S3:
|
||||
|
||||
```bash
|
||||
vantagectl backup --out - | aws s3 cp - s3://my-backups/vantage-$(date +%F).tar.gz
|
||||
```
|
||||
|
||||
An archive is as sensitive as a raw database dump — it carries every SSH key
|
||||
assignment, every secret group, every session-adjacent setting, in a form the
|
||||
right `KEY_ENCRYPTION_KEY` can decrypt. Whatever you pipe it into should
|
||||
encrypt it at rest; `vantagectl` itself does not.
|
||||
|
||||
## Checking a backup is real
|
||||
|
||||
```bash
|
||||
vantagectl verify /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \
|
||||
--mongo-uri mongodb://localhost:27017 --db vantage
|
||||
```
|
||||
|
||||
Each line of output answers a different question:
|
||||
|
||||
- **`Archive`** — every member's checksum still matches; the tarball has not
|
||||
been truncated or corrupted.
|
||||
- **`Archive key`** / **`Your key`** — the fingerprint stored in the archive
|
||||
next to the fingerprint of the `KEY_ENCRYPTION_KEY` in your environment.
|
||||
- **`Key match`** — whether those two fingerprints agree.
|
||||
- **`Live probe`** — given `--mongo-uri`, `verify` goes one step further and
|
||||
decrypts a real ciphertext value from that database with the key you hold.
|
||||
A fingerprint match proves two archives agree about a key; only the probe
|
||||
proves the key in your hand actually reads the data.
|
||||
|
||||
`verify` exits non-zero the moment anything above is wrong, which is what makes
|
||||
it worth putting on a schedule — a backup job that "succeeded" last night is
|
||||
not the same claim as a backup that will actually restore.
|
||||
|
||||
## Looking inside an archive
|
||||
|
||||
`inspect` prints an archive's manifest and touches no database at all — no
|
||||
`--mongo-uri`, no key. It is what to run against an archive of unknown origin,
|
||||
before deciding whether it is the one you want:
|
||||
|
||||
```bash
|
||||
vantagectl inspect /backups/vantage-backup-vantage-20260907T020000Z.tar.gz
|
||||
```
|
||||
|
||||
It reports when the archive was taken and on which host, the Vantage and
|
||||
MongoDB versions behind it, the database it came from, the key fingerprint (or
|
||||
that it carries none), every collection with its document count and size, and
|
||||
anything `--exclude` left out. Opening the archive verifies every member's
|
||||
checksum on the way, so a corrupt archive fails here too.
|
||||
|
||||
Reach for `verify` instead when the question is whether the key you hold opens
|
||||
it; reach for `inspect` when the question is what it is.
|
||||
|
||||
## Restoring
|
||||
|
||||
`restore` expects the target database to be empty. Pointed at one that already
|
||||
holds data, it refuses outright: there are no merge semantics, because merging
|
||||
two control planes reconciles nothing and upserting old data over new would
|
||||
resurrect revoked keys and deleted users.
|
||||
|
||||
```bash
|
||||
vantagectl restore /backups/vantage-backup-vantage-20260907T020000Z.tar.gz \
|
||||
--mongo-uri mongodb://localhost:27017 --db vantage_restore
|
||||
```
|
||||
|
||||
To overwrite a database that is not empty, add `--force`, which drops each
|
||||
collection named in the archive before loading it. `--force` always needs a
|
||||
second assurance, in one of two forms:
|
||||
|
||||
- `--confirm-db NAME`, naming the target exactly. A mismatch is refused. This
|
||||
works everywhere — on a terminal and in a Kubernetes Job, a CI step or a cron
|
||||
entry alike — and is the form to script.
|
||||
- Nothing, on a terminal: `--force` alone prompts you to type the target
|
||||
database's name back, a deliberate pause before something destructive.
|
||||
|
||||
Without a terminal and without `--confirm-db`, `--force` is refused: there is
|
||||
nobody there to prompt. Naming the database in the command itself means a
|
||||
copy-pasted invocation carries its intended target with it and cannot destroy a
|
||||
different one by accident.
|
||||
|
||||
`--force` drops only the collections the archive carries. Anything else already
|
||||
in the target is left alone and named in a warning, so an archive taken with
|
||||
`--exclude workflow_log_lines` restored over a live database tells you the old
|
||||
log lines are still there, joined to freshly restored runs. Dropping them
|
||||
instead would delete data you never asked to delete.
|
||||
|
||||
`restore` also refuses when the archive's key fingerprint does not match the
|
||||
`KEY_ENCRYPTION_KEY` in your environment — see "When the key is wrong" below.
|
||||
|
||||
## The restore drill
|
||||
|
||||
An untested backup is a hypothesis, not a backup. Rehearse the whole path,
|
||||
monthly:
|
||||
|
||||
1. Restore last night's archive into a scratch database:
|
||||
```bash
|
||||
vantagectl restore /backups/vantage-backup-vantage-<date>.tar.gz \
|
||||
--mongo-uri mongodb://localhost:27017 --db vantage_drill
|
||||
```
|
||||
2. Run `verify` against the result to confirm the data that landed is actually
|
||||
readable with your current key:
|
||||
```bash
|
||||
vantagectl verify /backups/vantage-backup-vantage-<date>.tar.gz \
|
||||
--mongo-uri mongodb://localhost:27017 --db vantage_drill
|
||||
```
|
||||
3. Drop the scratch database. It served its purpose.
|
||||
|
||||
The failure this catches is not "the archive is corrupt" — `verify` alone
|
||||
catches that. It is "the archive is fine but nobody can actually stand a
|
||||
control plane back up from it," which only a real restore proves.
|
||||
|
||||
## When the key is wrong
|
||||
|
||||
If `restore` finds the archive's key fingerprint does not match the
|
||||
`KEY_ENCRYPTION_KEY` you are running with, it stops. Passing
|
||||
`--ignore-key-mismatch` proceeds anyway, but says plainly which collections
|
||||
will come back with ciphertext nobody can read:
|
||||
|
||||
- `keys` — SSH private keys and passphrases
|
||||
- `secrets` — the vault
|
||||
- `auth_providers` — OIDC/SSO client secrets
|
||||
- `console_sessions` — RDP/VNC credentials
|
||||
|
||||
There is no way to recover that ciphertext afterwards. If you have reached
|
||||
this point, the right key was lost along with the chance to read those rows —
|
||||
the fix is to re-enter each of them by hand (re-upload SSH keys, re-save vault
|
||||
secrets, reconfigure SSO), not to keep searching for a way to decrypt what is
|
||||
already in the database.
|
||||
@@ -24,7 +24,23 @@ values is permanently unreadable.
|
||||
Store the key somewhere other than the server it protects.
|
||||
:::
|
||||
|
||||
## Backing up MongoDB
|
||||
:::info Use `vantagectl`
|
||||
[**Backup and restore**](./backup-and-restore.md) is the supported way to take
|
||||
and restore a backup. It writes an archive that carries a fingerprint of
|
||||
`KEY_ENCRYPTION_KEY` — never the key — so a restore taken with the wrong key
|
||||
**refuses** rather than silently producing a database whose secrets nobody can
|
||||
read. It also checksums every archive member before writing anything, and
|
||||
refuses to restore into a database that already holds data. A plain
|
||||
`mongodump` does none of that: it records nothing about which key the data was
|
||||
encrypted under, so a restore from one succeeds even when the key is wrong and
|
||||
the failure only shows up later, as unreadable secrets.
|
||||
|
||||
The rest of this page, past the table above, describes the `mongodump` /
|
||||
`mongorestore` fallback for an operator who does not have `vantagectl`
|
||||
available. Prefer the linked page.
|
||||
:::
|
||||
|
||||
## Backing up MongoDB (fallback, without `vantagectl`)
|
||||
|
||||
With the bundled Mongo container:
|
||||
|
||||
@@ -33,6 +49,13 @@ docker compose exec -T mongo mongodump --archive --gzip --db vantage \
|
||||
> /backups/vantage-$(date +%F).archive.gz
|
||||
```
|
||||
|
||||
:::warning
|
||||
This archive records nothing about which `KEY_ENCRYPTION_KEY` it was taken
|
||||
under. Restoring it with the wrong key produces a database that looks intact
|
||||
and is not — every secret in it is silently unreadable until something tries
|
||||
to decrypt one.
|
||||
:::
|
||||
|
||||
Restoring:
|
||||
|
||||
```bash
|
||||
@@ -69,12 +92,13 @@ What it does **not** do is reconcile the world. After a restore:
|
||||
|
||||
| What | When |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| MongoDB dump | Nightly, retained per your policy |
|
||||
| Backup | Nightly, retained per your policy |
|
||||
| Environment file | On change, held in a password manager or secret store |
|
||||
| Restore rehearsal | Occasionally, into a throwaway host |
|
||||
|
||||
Rehearse a restore now and again. It is the step most often skipped, and the one
|
||||
that finds the problems.
|
||||
that finds the problems. See [Backup and restore](./backup-and-restore.md) for
|
||||
the drill, and for `verify`, which checks a backup is real without a restore.
|
||||
|
||||
## Cloud instances
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ it is absent.
|
||||
| `VANTAGE_LICENSE` | no | | A licence supplied at startup, so an automated install does not have to paste one in |
|
||||
| `VANTAGE_TRIVY_DB_REF` | no | `ghcr.io/aquasecurity/trivy-db:2` | Where the vulnerability database is pulled from. Point it at a mirror for an air-gapped install |
|
||||
| `VANTAGE_VULNDB_DISABLED` | no | | `true` switches [vulnerability scanning](../vantage/vulnerabilities.md) off entirely. Findings already stored are still served, and still shown as stale |
|
||||
| `TRUSTED_PROXIES` | no | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | Comma-separated CIDRs or addresses of proxies allowed to set `X-Forwarded-For`. The shipped Docker Compose and Helm chart default to the private RFC1918 ranges, which covers Nginx Proxy Manager on the Docker bridge network and Traefik on a Kubernetes pod CIDR. An operator whose proxy sits on a public address must set this themselves, or every visitor behind it shares one address for rate-limiting purposes. Unset entirely (outside those shipped defaults) trusts none, so the client address is the direct peer. **On a LAN-only install, narrow this to your proxy's address.** The RFC1918 default trusts every private range, so a client on 192.168.0.0/16 reaching the server directly is itself a "trusted proxy" and can put whatever it likes in `X-Forwarded-For` — and, on the public status route, in `X-Forwarded-Host`. Behind a proxy on a public address, or with no proxy at all, that is not reachable; on a flat LAN it is |
|
||||
|
||||
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
|
||||
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
|
||||
|
||||
@@ -9,7 +9,7 @@ sidebar_label: Ports and networking
|
||||
| Port | Service | Who connects | Expose publicly |
|
||||
| ------- | ----------- | -------------------------------- | --------------- |
|
||||
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
|
||||
| `8080` | server API | The web app | No, firewall it |
|
||||
| `8080` | server API | Your reverse proxy | Not directly — proxied |
|
||||
| `9090` | server gRPC | Agents | **Yes** |
|
||||
| `4822` | guacd | The server | No, firewall it |
|
||||
| `27017` | MongoDB | The server | No |
|
||||
@@ -20,8 +20,8 @@ sidebar_label: Ports and networking
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["Browser"] -->|HTTPS| P["Reverse proxy"]
|
||||
P --> W["web :3000"]
|
||||
W --> S["server :8080"]
|
||||
P -->|"everything else"| W["web :3000"]
|
||||
P -->|"/api /auth /public /install* /update*"| S["server :8080"]
|
||||
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
|
||||
S --> G["guacd :4822"]
|
||||
G -->|"relayed over the :9090 stream"| A
|
||||
@@ -77,8 +77,20 @@ On a private network you can skip TLS instead, by setting `tls: false` in each
|
||||
|
||||
## Reverse proxy notes
|
||||
|
||||
- Point the proxy at `web:3000`. The web app reaches the API internally, so
|
||||
`8080` does not need publishing.
|
||||
- **The proxy routes two backends on one hostname**, and both are required:
|
||||
|
||||
| Path | Backend |
|
||||
| ------------------------------------------------------------- | ------------- |
|
||||
| `/api`, `/auth`, `/public`, `/install`, `/install.ps1`, `/update`, `/update.ps1` | `server:8080` |
|
||||
| everything else | `web:3000` |
|
||||
|
||||
The web app forwards nothing to the API. Sending the whole hostname to
|
||||
`web:3000` loads the interface and every request it makes answers `404` —
|
||||
including the login form.
|
||||
|
||||
- Both backends must be the **same** hostname and certificate. The browser
|
||||
calls `/api` relative to the page it is on, and the session cookie is
|
||||
host-only.
|
||||
- The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does
|
||||
not forward upgrade headers breaks the console and nothing else.
|
||||
- Workflow log streaming is a long-lived response. A short proxy read timeout
|
||||
|
||||
@@ -20,6 +20,13 @@ or is not 64 hex characters.
|
||||
|
||||
## Nobody can sign in
|
||||
|
||||
**Every request 404s and the interface loads fine.** Your reverse proxy sends
|
||||
the whole hostname to `web:3000`. `/api`, `/auth`, `/public`, `/install*` and
|
||||
`/update*` belong to `server:8080` and the web app forwards nothing — see
|
||||
[Ports and networking](./ports-and-networking.md#reverse-proxy-notes). The
|
||||
tell is `curl -si https://<your-host>/auth/bootstrap-status` returning HTML
|
||||
with `x-powered-by: Next.js` instead of JSON.
|
||||
|
||||
**`/setup` appears when users already exist.** The server is pointed at a
|
||||
different database than you think. Check the database name in `MONGO_URI`,
|
||||
which is taken from the end of the URI.
|
||||
@@ -95,6 +102,53 @@ instantaneous.
|
||||
- The keyword no longer appears in the response body.
|
||||
- Retries are `0`, so a single dropped packet flips the state.
|
||||
|
||||
### The check gets a 403, 429 or a CAPTCHA page
|
||||
|
||||
The endpoint is fine and answers a browser normally, but the monitor records a
|
||||
status it never sees by hand. Something between Vantage and the service is
|
||||
blocking automated traffic: a CDN, a WAF, a bot-protection product, a reverse
|
||||
proxy rule, or a rate limiter. The response usually comes from that layer and
|
||||
never reaches the origin at all, so nothing appears in the application's own
|
||||
logs.
|
||||
|
||||
Two things make it hard to spot. The check runs from the control plane's or the
|
||||
agent's address rather than yours, and those addresses are often datacenter
|
||||
ranges that bot protection scores badly. And a browser test proves nothing,
|
||||
because a browser is exactly what the blocking layer is willing to serve.
|
||||
|
||||
Every HTTP check Vantage makes identifies itself:
|
||||
|
||||
```
|
||||
User-Agent: Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)
|
||||
```
|
||||
|
||||
That string is the hook to allow the check through. In whichever product is
|
||||
doing the blocking, add a rule that skips bot protection, managed rules and rate
|
||||
limiting for requests carrying it — Cloudflare, AWS WAF, Azure Front Door,
|
||||
Akamai, Fastly, Imperva, Sucuri, ModSecurity, nginx and HAProxy all match on a
|
||||
request header. The shape of the rule is the same everywhere:
|
||||
|
||||
> If the host is *yours*, the path is *the one being monitored*, and the
|
||||
> User-Agent contains `Vantage-Monitor`, then skip the protection.
|
||||
|
||||
Three details are worth getting right:
|
||||
|
||||
- **Match on `contains`, not equality.** The version in the string moves. An
|
||||
exact match breaks silently on an upgrade, and the symptom is a monitor that
|
||||
goes down on deploy day.
|
||||
- **Keep the rule narrow.** Scope it to the specific host and path being
|
||||
monitored. A User-Agent is not a secret — anyone can send it — so a rule that
|
||||
skips protection site-wide on that string alone is a bypass you have
|
||||
published.
|
||||
- **Allow the source address too, where you can.** Combining the User-Agent with
|
||||
the checker's IP is stronger than either alone. Find the address in your
|
||||
blocking product's own event log; it is whichever client IP was blocked on the
|
||||
monitored path.
|
||||
|
||||
If the endpoint genuinely needs authentication rather than an exception, monitor
|
||||
a purpose-built health path that does not, and leave the protected paths
|
||||
protected.
|
||||
|
||||
## Notifications are not arriving
|
||||
|
||||
Use the channel **Test** button. It goes through the real delivery path, so a
|
||||
@@ -113,6 +167,32 @@ needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
|
||||
| Instance degraded despite a valid-looking licence | It expired more than a few days ago. Pasting a new one still works, which is how you recover |
|
||||
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
|
||||
|
||||
## A status page 404s or shows no data
|
||||
|
||||
**404, and it should be published.** Check the **Published** toggle on the
|
||||
page's editor — an unpublished page answers *not found* for everyone,
|
||||
including you, with no session exemption. Also check the host: the public URL
|
||||
is `<your-instance>.vantage.<yourdomain>/status/<page-id>`, the same
|
||||
per-instance subdomain everything else in Vantage uses. A wrong or missing
|
||||
subdomain resolves to no instance at all, which is also a 404.
|
||||
|
||||
Third possibility: `/public` is not routed to the server. Check with
|
||||
`curl -si https://<your-instance>.vantage.<yourdomain>/public/status/<page-id>`
|
||||
— JSON is correct, HTML carrying `x-powered-by: Next.js` means the proxy sent
|
||||
that prefix to the web app.
|
||||
|
||||
**Loads, but shows an explanation instead of components.** This is not a
|
||||
fault — it is the page working as designed. It means either the licence has
|
||||
lapsed (a self-hosted instance past its grace period, or a cloud instance
|
||||
between billing events) or the current tier does not include the **Status
|
||||
pages** feature. Fix the licence or the plan and the same link starts serving
|
||||
data again with no republish needed.
|
||||
|
||||
**One component reads `Unknown`.** The monitor behind it was deleted while
|
||||
still listed on the page. Nothing is checking it any more, so the page says so
|
||||
rather than showing a stale up or down. Remove the component from the page,
|
||||
or point it at a replacement monitor, in the page's editor.
|
||||
|
||||
## HQ portal problems
|
||||
|
||||
The portal is a hosted service, so problems with it are ours to fix rather than
|
||||
|
||||
@@ -64,6 +64,15 @@ Posts the alert as message content.
|
||||
|
||||
Port `465` uses implicit TLS; anything else uses STARTTLS.
|
||||
|
||||
### Credentials are never read back
|
||||
|
||||
The SMTP `password`, the Telegram `token` and the webhook, Slack and Discord
|
||||
`url`s come back from `GET /api/channels` as `••••••••` — a webhook URL is the
|
||||
authorisation to post to that channel, so it is treated as a credential like
|
||||
the rest. Writing that value back unchanged keeps the stored one, which is what
|
||||
lets you rename a channel without retyping its password. Anything else you send
|
||||
is written as given, so clearing the field clears the credential.
|
||||
|
||||
Alert emails look like the rest of the mail Vantage sends you.
|
||||
|
||||
## The message
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
id: status-pages
|
||||
title: Status pages
|
||||
sidebar_label: Status pages
|
||||
---
|
||||
|
||||
A status page is a public page reporting a chosen set of monitors as up-front
|
||||
components, with a 90-day history and an uptime percentage per component. It
|
||||
needs no session and no token to read — anyone with the link can open it,
|
||||
which is the point: it is what you hand a customer instead of an incident
|
||||
email.
|
||||
|
||||
Requires the **Status pages** licence feature. If the licence lapses, or the
|
||||
tier does not include the feature, the page keeps serving — it renders an
|
||||
explanation rather than data or a broken page, so a customer who follows an
|
||||
old link never sees an error.
|
||||
|
||||
## Creating a page
|
||||
|
||||
From **Status pages**, choose a page id and a title. The id is 3–40 characters
|
||||
of lowercase letters, digits and `-`, starting and ending with a letter or
|
||||
digit. It becomes part of the public URL:
|
||||
|
||||
```
|
||||
https://<your-vantage-address>/status/<page-id>
|
||||
```
|
||||
|
||||
On **Vantage Cloud** that address is your instance's own subdomain, so the page
|
||||
is at `https://<your-instance>.vantage.hostxtra.co.uk/status/<page-id>`.
|
||||
|
||||
On a **self-hosted** install it is whatever address you reach Vantage on —
|
||||
`https://vantage.acme.com/status/<page-id>`, or an IP and port on a LAN
|
||||
install. A self-hosted install serves exactly one Vantage instance, so no
|
||||
subdomain is needed to say which one you mean. The **Copy** control next to the
|
||||
page address in the editor gives you the exact URL for your install, which is
|
||||
the one to hand out.
|
||||
|
||||
**The page id cannot be changed after creation.** Once you have shared the
|
||||
link, changing the id would break it, so pick something you would still be
|
||||
happy with in a year — `platform`, `api`, a customer's own name for a
|
||||
dedicated page.
|
||||
|
||||
## Draft versus published
|
||||
|
||||
A new page starts unpublished. Unpublished pages answer *not found* to
|
||||
anyone who requests them, including you, from a browser without a session —
|
||||
so you can build out the components and copy before announcing it. Toggle
|
||||
**Published** when it is ready. Un-publishing later takes it back to *not
|
||||
found* rather than deleting anything.
|
||||
|
||||
**Delete page**, in the editor header, is the only way to correct a page id you
|
||||
regret — the id is fixed once created. It takes the page, its sections and its
|
||||
authored incidents with it; monitors and their history are untouched. If you
|
||||
only want the page off the internet, un-publish it instead.
|
||||
|
||||
## Sections and components
|
||||
|
||||
A page is organised into **sections** — arbitrary groupings such as "API" or
|
||||
"Region: EU" — each holding one or more **components**. A component is a
|
||||
monitor plus a **display name** you choose for this page.
|
||||
|
||||
The display name is never the monitor's own name unless you type it in. An
|
||||
internal monitor name ("prod-db-primary-eu1") is rarely what you want a
|
||||
customer reading; give it whatever name makes sense to them, and change it
|
||||
for a different page without touching the monitor.
|
||||
|
||||
If a monitor listed on a page is later deleted, its component still appears —
|
||||
reading `Unknown` rather than up or down, because nothing is checking it any
|
||||
more and claiming otherwise would be a false claim of health.
|
||||
|
||||
## What a visitor sees
|
||||
|
||||
- Component name, current state (up / down / under maintenance / pending /
|
||||
unknown) and a 90-day uptime percentage. **Pending** is a monitor that has
|
||||
been added but has not produced a result yet; **unknown** is one nothing is
|
||||
checking any more.
|
||||
- A 90-day history bar per component.
|
||||
- Any active incidents, upcoming maintenance, and a rolling history of both.
|
||||
- An optional banner across the top of the page, for anything you want said
|
||||
regardless of component state. It is one notice with one appearance — there
|
||||
are no severity levels to choose between.
|
||||
|
||||
A visitor never sees a target URL, host or port, the check's expected status
|
||||
or keyword, latency, a certificate expiry date, failure text, or which
|
||||
notification channel is attached. That is a deliberate boundary, not an
|
||||
oversight: nothing that would tell a stranger how your infrastructure is
|
||||
reachable is on this page.
|
||||
|
||||
## Incidents and maintenance
|
||||
|
||||
Two kinds of entries appear on a page's timeline:
|
||||
|
||||
- **Automatic** — a monitor going down opens an incident on any page that
|
||||
lists it, with no action from you. These appear the moment the monitor's
|
||||
state changes and close the moment it recovers.
|
||||
- **Authored** — an incident or maintenance window you create by hand, with
|
||||
its own title, impact and a set of affected components you choose. You
|
||||
post updates to it (Investigating → Identified → Monitoring → Resolved) as
|
||||
the situation develops, and each update is timestamped and kept on the
|
||||
page's history.
|
||||
|
||||
An authored incident is attached to one or more pages explicitly when you
|
||||
create it — it does not follow a monitor onto every page that monitor happens
|
||||
to be listed on.
|
||||
|
||||
### Scheduling maintenance
|
||||
|
||||
A maintenance window has a scheduled start and end (the end must be after the
|
||||
start) and moves through Scheduled → In progress → Completed. While a window
|
||||
is in progress and its affected components are within the scheduled time,
|
||||
those components are drawn as "under maintenance" instead of up or down.
|
||||
|
||||
**Maintenance changes how a day is drawn, never the uptime number itself.**
|
||||
The 90-day percentage is computed from what actually happened — a component
|
||||
that stayed up throughout a maintenance window still shows as up in its
|
||||
history, it is only the live status pill that reads "under maintenance" for
|
||||
the duration.
|
||||
|
||||
## Delay before an update appears
|
||||
|
||||
A visitor's read of a page is cached for up to 30 seconds, so posting an
|
||||
update or flipping Published does not necessarily change what a visitor sees
|
||||
instantly — though most authoring actions invalidate that cache immediately,
|
||||
so in practice it usually shows within a second or two. If a change genuinely
|
||||
does not appear, reloading after 30 seconds always will.
|
||||
+2
-1
@@ -29,6 +29,7 @@ const sidebars: SidebarsConfig = {
|
||||
"vantage/vulnerabilities",
|
||||
"vantage/workloads",
|
||||
"vantage/notification-channels",
|
||||
"vantage/status-pages",
|
||||
"vantage/secrets",
|
||||
"vantage/browser-console",
|
||||
"vantage/audit-log",
|
||||
@@ -48,7 +49,7 @@ const sidebars: SidebarsConfig = {
|
||||
{
|
||||
type: "category",
|
||||
label: "Operations",
|
||||
items: ["operations/upgrading", "operations/backups", "operations/agent-updates"],
|
||||
items: ["operations/upgrading", "operations/backups", "operations/backup-and-restore", "operations/agent-updates"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,6 @@ use (
|
||||
./admin
|
||||
./agent
|
||||
./server
|
||||
./shared
|
||||
./sitesvc
|
||||
./vantagectl
|
||||
)
|
||||
|
||||
+3
-3
@@ -1,5 +1,6 @@
|
||||
cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
|
||||
github.com/Intevation/gval v1.3.0/go.mod h1:xmGyGpP5be12EL0P12h+dqiYG8qn2j3PJxIgkoOHO5o=
|
||||
github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNYnibV7CiaYIw=
|
||||
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
||||
@@ -27,9 +28,9 @@ github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/josephburnett/jd/v2 v2.3.0/go.mod h1:0I5+gbo7y8diuajJjm79AF44eqTheSJy1K7DSbIUFAQ=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
@@ -41,7 +42,6 @@ github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH
|
||||
github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
@@ -67,7 +67,6 @@ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
@@ -78,6 +77,7 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
|
||||
+14
-8
@@ -1,21 +1,27 @@
|
||||
# Build stage
|
||||
#
|
||||
# Context is the repository root, not server/, because server depends on the
|
||||
# shared module through a replace directive.
|
||||
# Context is still the repository root, not server/, because the runtime stage
|
||||
# copies default_steps/ from it.
|
||||
#
|
||||
# vantage-shared is a private module, so every step that resolves it needs a
|
||||
# credential. It arrives as a BuildKit secret rather than a build arg: an arg
|
||||
# is baked into the builder layer's history, and this one is a Gitea token.
|
||||
FROM golang:1.26 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Manifests first so the dependency layer caches independently of source edits.
|
||||
COPY shared/go.mod shared/go.sum ./shared/
|
||||
COPY server/go.mod server/go.sum ./server/
|
||||
RUN cd server && go mod download
|
||||
ENV GOPRIVATE=gitea.hostxtra.co.uk/*
|
||||
|
||||
# Manifests first so the dependency layer caches independently of source edits.
|
||||
COPY server/go.mod server/go.sum ./server/
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
cd server && go mod download
|
||||
|
||||
COPY shared/ ./shared/
|
||||
COPY server/ ./server/
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
RUN --mount=type=secret,id=netrc,target=/root/.netrc \
|
||||
cd server && CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
|
||||
|
||||
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
|
||||
|
||||
+22
-4
@@ -51,10 +51,10 @@ import (
|
||||
// @name Authorization
|
||||
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
|
||||
|
||||
// @securityDefinitions.apikey esoAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
|
||||
// @securityDefinitions.apikey esoAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
|
||||
func main() {
|
||||
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
|
||||
|
||||
@@ -162,6 +162,10 @@ func runSchemaSetup() {
|
||||
log.Printf("warning: failed to ensure workflow indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureMonitorSampleIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure monitor sample indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureVulnIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure vuln indexes: %v", err)
|
||||
}
|
||||
@@ -170,6 +174,10 @@ func runSchemaSetup() {
|
||||
log.Printf("warning: failed to ensure workload indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureStatusPageIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure status page indexes: %v", err)
|
||||
}
|
||||
|
||||
if err := services.EnsureAuditIndexes(); err != nil {
|
||||
log.Printf("warning: failed to ensure audit indexes: %v", err)
|
||||
}
|
||||
@@ -255,9 +263,19 @@ func serve() {
|
||||
})
|
||||
|
||||
r := gin.New()
|
||||
// Without this gin trusts every proxy and ClientIP() is whatever the
|
||||
// caller wrote in X-Forwarded-For. That was survivable while ClientIP()
|
||||
// only produced audit strings; the public status limiter makes it load
|
||||
// bearing. Empty means trust nobody, which is correct for a direct
|
||||
// exposure and wrong behind a proxy — hence the explicit setting.
|
||||
if err := r.SetTrustedProxies(api.TrustedProxies()); err != nil {
|
||||
log.Fatalf("trusted proxies: %v", err)
|
||||
}
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
|
||||
r.Use(corsMiddleware())
|
||||
services.SetStatusRedis(auth.Redis())
|
||||
|
||||
api.RegisterRoutes(r)
|
||||
|
||||
if err := api.AssertScopeMapComplete(r); err != nil {
|
||||
|
||||
+1
-3
@@ -37,7 +37,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -77,5 +77,3 @@ require (
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace gitea.hostxtra.co.uk/mrhid6/vantage/shared => ../shared
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M=
|
||||
|
||||
@@ -34,7 +34,14 @@ func listChannels(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, channels)
|
||||
// Redacted here rather than in the service: the dispatchers read the same
|
||||
// documents and need the real credentials, so the masking belongs to the
|
||||
// boundary that hands them to a client.
|
||||
out := make([]models.NotificationChannel, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
out = append(out, ch.Redacted())
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// createChannel godoc
|
||||
@@ -69,7 +76,7 @@ func createChannel(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created)
|
||||
c.JSON(http.StatusCreated, created.Redacted())
|
||||
}
|
||||
|
||||
// updateChannel godoc
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,10 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/auth/oidc/:providerId/callback", auth.HandleSSOCallback)
|
||||
r.GET("/auth/providers", auth.HandleListPublicProviders)
|
||||
|
||||
// Completely public: no session, no token, no licence gate. Mounted here
|
||||
// rather than under /api precisely so that none of those apply.
|
||||
r.GET("/public/status/:pageId", RateLimitPublicStatus(), getPublicStatusPage)
|
||||
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(auth.Middleware())
|
||||
// Scope enforcement sits between authentication and the licence gate, and
|
||||
@@ -162,6 +166,8 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
|
||||
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
|
||||
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
|
||||
|
||||
registerStatusPageRoutes(apiGroup)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
@@ -19,6 +20,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.DELETE("/monitors/:id", deleteMonitor)
|
||||
g.GET("/monitors/:id/incidents", getMonitorIncidents)
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
g.GET("/monitors/:id/samples", getMonitorSamples)
|
||||
}
|
||||
|
||||
// listMonitors godoc
|
||||
@@ -111,7 +113,7 @@ func getMonitor(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param body body object{name=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
|
||||
// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
@@ -121,6 +123,7 @@ func getMonitor(c *gin.Context) {
|
||||
func updateMonitor(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Group *string `json:"group"`
|
||||
Type *string `json:"type"`
|
||||
Target *models.MonitorTarget `json:"target"`
|
||||
IntervalSec *int `json:"interval_sec"`
|
||||
@@ -137,6 +140,9 @@ func updateMonitor(c *gin.Context) {
|
||||
if body.Name != nil {
|
||||
upd["name"] = *body.Name
|
||||
}
|
||||
if body.Group != nil {
|
||||
upd["group"] = *body.Group
|
||||
}
|
||||
if body.Type != nil {
|
||||
upd["type"] = *body.Type
|
||||
}
|
||||
@@ -217,6 +223,49 @@ func getMonitorIncidents(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
// getMonitorSamples godoc
|
||||
//
|
||||
// @Summary Get a monitor's individual check results
|
||||
// @Description Raw check results for the last `minutes` minutes, oldest first. Samples expire after 48 hours; use the uptime rollups for longer ranges.
|
||||
// @Tags monitors
|
||||
// @Produce json
|
||||
// @Param id path string true "Monitor ID"
|
||||
// @Param minutes query int false "Window in minutes (default 60, max 2880)"
|
||||
// @Success 200 {array} models.MonitorSample
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /monitors/{id}/samples [get]
|
||||
func getMonitorSamples(c *gin.Context) {
|
||||
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
// Clamped rather than rejected: the window is a view setting, and the only
|
||||
// honest answer past the TTL is the shorter window anyway.
|
||||
minutes := 60
|
||||
if raw := c.Query("minutes"); raw != "" {
|
||||
if n, convErr := strconv.Atoi(raw); convErr == nil && n > 0 {
|
||||
minutes = n
|
||||
}
|
||||
}
|
||||
if max := int(services.MonitorSampleTTL.Minutes()); minutes > max {
|
||||
minutes = max
|
||||
}
|
||||
samples, err := services.MonitorSamples(auth.InstanceID(c), c.Param("id"), time.Now().Add(-time.Duration(minutes)*time.Minute))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, samples)
|
||||
}
|
||||
|
||||
// getMonitorUptime godoc
|
||||
//
|
||||
// @Summary Get a monitor's uptime rollups
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user