Compare commits
231
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0fbf5b9ba | ||
|
|
ddf0814803 | ||
|
|
2bc15648b7 | ||
|
|
7f348b7b2b | ||
|
|
a71fd9a9f4 | ||
|
|
36848a519a | ||
|
|
ea6d0b969c | ||
|
|
4ba9983cf8 | ||
|
|
8caaa4540f | ||
|
|
42f0cb67cb | ||
|
|
1dfb3cc28c | ||
|
|
b51e87477e | ||
|
|
05c0ad43d2 | ||
|
|
4a07af049c | ||
|
|
057c193e26 | ||
|
|
38a731f269 | ||
|
|
06d69590fc | ||
|
|
6adee810dc | ||
|
|
00d4307346 | ||
|
|
7e767ecb4f | ||
|
|
18495dba68 | ||
|
|
689d0e1d5b | ||
|
|
95527b3956 | ||
|
|
225b53bfa7 | ||
|
|
965419b2b8 | ||
|
|
f6988b0f1e | ||
|
|
0784ef3719 | ||
|
|
9df4a29210 | ||
|
|
4c88d6e768 | ||
|
|
a85a354e57 | ||
|
|
9b18d09d9b | ||
|
|
a398da0eac | ||
|
|
edb8406e05 | ||
|
|
bfd185adbb | ||
|
|
182752d9ab | ||
|
|
3b4c87a292 | ||
|
|
2685e9ad06 | ||
|
|
4de67e4bea | ||
|
|
524ccc6412 | ||
|
|
be4f488db3 | ||
|
|
2f60b81962 | ||
|
|
92692de94d | ||
|
|
a5f9fca59e | ||
|
|
72e5228351 | ||
|
|
33b5ec0788 | ||
|
|
1b718e7c59 | ||
|
|
6ad65a1242 | ||
|
|
a41f2b26cc | ||
|
|
71f9a9dca5 | ||
|
|
449684ceaa | ||
|
|
cdc50b7aaf | ||
|
|
f534b74066 | ||
|
|
3c15ee15ef | ||
|
|
45a4f968d6 | ||
|
|
df09e42cf2 | ||
|
|
ee427ed6e1 | ||
|
|
5856deede3 | ||
|
|
e02b263054 | ||
|
|
1d6c89c368 | ||
|
|
0edfddb710 | ||
|
|
71a4f53bed | ||
|
|
de7350fce9 | ||
|
|
21786fa1a8 | ||
|
|
84f9587b9a | ||
|
|
a20e165ac2 | ||
|
|
ed260cd86c | ||
|
|
99cd2a8ec3 | ||
|
|
a228ab24a0 | ||
|
|
3acbf01d46 | ||
|
|
675689a458 | ||
|
|
42f3f3e640 | ||
|
|
21238fe707 | ||
|
|
ef86ef04a1 | ||
|
|
727bb09eff | ||
|
|
1fe4ba5999 | ||
|
|
e434beec7a | ||
|
|
fe1dfe472a | ||
|
|
0cfaf6670c | ||
|
|
4d67341ba5 | ||
|
|
1fa9160c59 | ||
|
|
d559cccd44 | ||
|
|
0684d84609 | ||
|
|
78f1bf853c | ||
|
|
e28238191d | ||
|
|
82bcc5776f | ||
|
|
1993802c38 | ||
|
|
5db49b6b0e | ||
|
|
0c15b25ecd | ||
|
|
0c21765da3 | ||
|
|
4ff8fc8d51 | ||
|
|
483053b9a2 | ||
|
|
fd4c51f3db | ||
|
|
1b351cfca4 | ||
|
|
cf9d85b3cd | ||
|
|
6a4ef5b6c6 | ||
|
|
501cf4e733 | ||
|
|
89c21d752a | ||
|
|
0e38d9d500 | ||
|
|
3511c34daa | ||
|
|
0838d1d735 | ||
|
|
d1769fc886 | ||
|
|
6dced22499 | ||
|
|
5cee53dc5f | ||
|
|
81248bb159 | ||
|
|
6354d54de8 | ||
|
|
da6d64f95c | ||
|
|
9ba3d4a61f | ||
|
|
eee236a072 | ||
|
|
9df89e2db4 | ||
|
|
f60c509b47 | ||
|
|
84dfcfeac7 | ||
|
|
5dda3b5c4a | ||
|
|
db64320bd8 | ||
|
|
583f60771c | ||
|
|
a92c3190c2 | ||
|
|
3a6d24fe0e | ||
|
|
c277ecff44 | ||
|
|
bd690c94c3 | ||
|
|
a22fdf197e | ||
|
|
bd24b03cac | ||
|
|
3afc4ab012 | ||
|
|
d1ac3e98ce | ||
|
|
5bba54f3e5 | ||
|
|
fe7bc300e2 | ||
|
|
00c03c365d | ||
|
|
dc8dd3dd58 | ||
|
|
85a8865892 | ||
|
|
50a9ac5fdc | ||
|
|
3388d2f895 | ||
|
|
3a77fc2abd | ||
|
|
3d59836d0c | ||
|
|
d9184312aa | ||
|
|
b9802e6b04 | ||
|
|
c2635ed51a | ||
|
|
b21ac05547 | ||
|
|
484b620867 | ||
|
|
439bc2ed7d | ||
|
|
a1e6986a64 | ||
|
|
d0e1cc4ad6 | ||
|
|
b877024365 | ||
|
|
2de7ac116b | ||
|
|
fa1fd14ed1 | ||
|
|
d1b3cd2f74 | ||
|
|
e00a0da5d9 | ||
|
|
fef0b7c7a1 | ||
|
|
efd29dc259 | ||
|
|
13cd41d202 | ||
|
|
3530ce6cb7 | ||
|
|
09522c2566 | ||
|
|
80f0afb28b | ||
|
|
287bd9657b | ||
|
|
b5f684c4fe | ||
|
|
1f08e90009 | ||
|
|
6881d92d0a | ||
|
|
5e016c6584 | ||
|
|
537b8758ff | ||
|
|
c03360333b | ||
|
|
fa7c5d341d | ||
|
|
b6fc8c3f77 | ||
|
|
37f2c1457e | ||
|
|
3a626922a5 | ||
|
|
dde47de145 | ||
|
|
f3b9f6f286 | ||
|
|
f1c3f67864 | ||
|
|
8f5873afca | ||
|
|
e22faebfcd | ||
|
|
e2b01b62a5 | ||
|
|
0858693d57 | ||
|
|
45f7c0c393 | ||
|
|
c56bfb7270 | ||
|
|
eb45072031 | ||
|
|
1e2132c1a1 | ||
|
|
19ef773690 | ||
|
|
c5aae0614a | ||
|
|
17d97aaf52 | ||
|
|
1fb9bd827f | ||
|
|
8699dc5b7e | ||
|
|
71240f183c | ||
|
|
01e8b0ba44 | ||
|
|
2aa4784518 | ||
|
|
f611cae438 | ||
|
|
1eb98ef962 | ||
|
|
6f86496f10 | ||
|
|
57a9b18102 | ||
|
|
36995fa62b | ||
|
|
9121fc461f | ||
|
|
fc56bae5f9 | ||
|
|
ac75b3ef76 | ||
|
|
e6fe463216 | ||
|
|
8528f14ed7 | ||
|
|
df1d9658f5 | ||
|
|
9f9b384481 | ||
|
|
165114471f | ||
|
|
de78688093 | ||
|
|
bbf9f72fd3 | ||
|
|
978b665aa6 | ||
|
|
1fe608f531 | ||
|
|
1e1546cb60 | ||
|
|
119d8694d1 | ||
|
|
8d43c689f5 | ||
|
|
05f10ed3c9 | ||
|
|
c0bec3737b | ||
|
|
59d147fe4d | ||
|
|
9e38a01e3d | ||
|
|
20a302f84a | ||
|
|
ba2e263d00 | ||
|
|
a000703199 | ||
|
|
8fcda63742 | ||
|
|
3363ac9dad | ||
|
|
a7e338b171 | ||
|
|
bc79daab48 | ||
|
|
d3d8dba3ff | ||
|
|
6d047e25ab | ||
|
|
ed4c39650c | ||
|
|
7b8fa4a8a0 | ||
|
|
8a02c35ec9 | ||
|
|
487de34a50 | ||
|
|
0424547dd4 | ||
|
|
5be9ddb2e5 | ||
|
|
bc6c7cdb5a | ||
|
|
f46fb7fc0e | ||
|
|
d9945882e5 | ||
|
|
9db16522e3 | ||
|
|
6070972f9a | ||
|
|
31e0000306 | ||
|
|
26567766a9 | ||
|
|
97dc6feaca | ||
|
|
f10e61cde4 | ||
|
|
febe48a974 | ||
|
|
766dcabfde | ||
|
|
c9dab99271 |
@@ -0,0 +1 @@
|
||||
graphify-out/graph.json merge=graphify
|
||||
@@ -0,0 +1,221 @@
|
||||
name: Chart Release
|
||||
|
||||
on:
|
||||
# Every push that touches the chart is validated. Publishing is separate and
|
||||
# deliberate: a chart version is immutable in the registry once pushed, so
|
||||
# it must come from a tag someone chose, not from whatever landed on main.
|
||||
# No `paths` filter on push, deliberately. A paths filter applies to tag
|
||||
# pushes too, so tagging a commit that happened not to touch the chart
|
||||
# would skip the publish entirely — a release that silently does nothing.
|
||||
# Validation is seconds of helm rendering; running it on every push to main
|
||||
# is cheaper than that failure mode.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "chart/v*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "deploy/chart/**"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CHART_DIR: deploy/chart/vantage
|
||||
HELM_VERSION: v3.16.3
|
||||
|
||||
jobs:
|
||||
chart:
|
||||
runs-on: ubuntu-docker
|
||||
container: alpine:3.21
|
||||
steps:
|
||||
# git for actions/checkout, curl for both the Helm download and the
|
||||
# registry upload, tar because the Helm tarball is not self-extracting.
|
||||
- name: Setup
|
||||
run: apk add --no-cache bash curl git tar nodejs npm
|
||||
|
||||
- name: Install Helm
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \
|
||||
| tar -xz -C /tmp linux-amd64/helm
|
||||
mv /tmp/linux-amd64/helm /usr/local/bin/helm
|
||||
helm version --short
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Lint
|
||||
run: helm lint "$CHART_DIR"
|
||||
|
||||
# Rendering is the real test. `helm lint` accepts a chart whose
|
||||
# templates fail to execute, and every guard in this chart is a
|
||||
# template `fail` that only fires during rendering.
|
||||
- name: Render default values
|
||||
run: helm template test "$CHART_DIR" > /dev/null
|
||||
|
||||
- name: Render a multi-replica install
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set server.replicaCount=3 \
|
||||
--set web.replicaCount=3 > /dev/null
|
||||
|
||||
# The reaper deletes whole instances, so "does this env appear only
|
||||
# in cloud mode" is worth asserting rather than eyeballing.
|
||||
- name: Check the reaper is cloud-only
|
||||
run: |
|
||||
set -eu
|
||||
if helm template test "$CHART_DIR" | grep -q FREE_INSTANCE_REAP_AFTER; then
|
||||
echo "FREE_INSTANCE_REAP_AFTER is set on a self-hosted render"
|
||||
exit 1
|
||||
fi
|
||||
if ! helm template test "$CHART_DIR" \
|
||||
--set server.env.deploymentType=cloud \
|
||||
| grep -q FREE_INSTANCE_REAP_AFTER; then
|
||||
echo "FREE_INSTANCE_REAP_AFTER is missing from a cloud render"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: reaper configured in cloud mode only"
|
||||
|
||||
- name: Render against external Redis and MongoDB
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set redis.enabled=false \
|
||||
--set redis.addr=redis.example.com:6379 \
|
||||
--set mongo.enabled=false \
|
||||
--set server.env.mongoUri=mongodb://mongo.example.com:27017/vantage > /dev/null
|
||||
|
||||
- name: Render with the Traefik ingress
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.host=agents.example.com \
|
||||
--set ingress.tls.certResolver=letsencrypt \
|
||||
--set server.env.grpcHost=agents.example.com:443 > /dev/null
|
||||
|
||||
# The shape the cloud deployment actually uses: a wildcard tenant
|
||||
# namespace, /api and /auth routed at the edge, and no apex — that
|
||||
# belongs to the marketing site, which this chart does not deploy.
|
||||
- name: Render a wildcard host with edge-routed API paths
|
||||
run: |
|
||||
helm template test "$CHART_DIR" \
|
||||
--set ingress.enabled=true \
|
||||
--set 'ingress.web.host=*.vantage.example.com' \
|
||||
--set ingress.api.enabled=true \
|
||||
--set ingress.grpc.host=agents.example.com \
|
||||
--set server.env.grpcHost=agents.example.com:443 \
|
||||
--set ingress.tls.secretName=vantage-tls \
|
||||
--set ingress.tls.grpcSecretName=agents-tls > /dev/null
|
||||
|
||||
# The guards are load-bearing, so their absence is a regression the
|
||||
# same way a broken render is. Each of these must fail.
|
||||
- name: Check the guards still refuse bad values
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
refuses() {
|
||||
desc="$1"; shift
|
||||
if helm template test "$CHART_DIR" "$@" > /dev/null 2>&1; then
|
||||
echo "GUARD MISSING: $desc was accepted"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: refused $desc"
|
||||
}
|
||||
|
||||
refuses "mongo disabled with an in-chart URI" \
|
||||
--set mongo.enabled=false
|
||||
refuses "redis disabled with no external address" \
|
||||
--set redis.enabled=false
|
||||
refuses "multiple replicas on a ReadWriteOnce volume" \
|
||||
--set server.replicaCount=2 --set server.persistence.enabled=true
|
||||
refuses "ingress with no web host" \
|
||||
--set ingress.enabled=true
|
||||
refuses "edge-routed API with an empty path list" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set ingress.grpc.enabled=false \
|
||||
--set ingress.api.enabled=true \
|
||||
--set 'ingress.api.paths=null'
|
||||
refuses "gRPC ingress with no host" \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.web.host=vantage.example.com \
|
||||
--set server.env.grpcHost=agents.example.com:443
|
||||
refuses "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
|
||||
|
||||
- name: Read the chart version
|
||||
id: chart
|
||||
run: |
|
||||
set -eu
|
||||
VERSION="$(grep '^version:' "$CHART_DIR/Chart.yaml" | awk '{print $2}')"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "chart version is $VERSION"
|
||||
|
||||
# Chart.yaml is the source of truth for the version; the tag only
|
||||
# says "publish this one". A mismatch is a mistake worth stopping
|
||||
# for — the alternative is stamping the tag over Chart.yaml, which
|
||||
# leaves the repository disagreeing with what was published.
|
||||
- name: Check the tag matches Chart.yaml
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
run: |
|
||||
set -eu
|
||||
TAG_VERSION="${GITHUB_REF_NAME#chart/v}"
|
||||
CHART_VERSION="${{ steps.chart.outputs.version }}"
|
||||
if [ "$TAG_VERSION" != "$CHART_VERSION" ]; then
|
||||
echo "tag chart/v$TAG_VERSION does not match Chart.yaml version $CHART_VERSION"
|
||||
echo "bump version: in $CHART_DIR/Chart.yaml, or retag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
helm package "$CHART_DIR" --destination dist
|
||||
ls -l dist
|
||||
|
||||
- name: Publish to the Gitea chart registry
|
||||
if: startsWith(github.ref, 'refs/tags/chart/v')
|
||||
env:
|
||||
# github.server_url is this Gitea instance, so the registry
|
||||
# host needs no variable of its own and cannot drift from it.
|
||||
REGISTRY: ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm/api/charts
|
||||
# The same pair server-deploy.yml uses for `docker login`.
|
||||
# RELEASE_TOKEN, not REGISTRY_PASSWORD: the latter is named in
|
||||
# the docs but set by no workflow, and an unset secret becomes
|
||||
# an empty password, which Gitea reports as "Failed to
|
||||
# authenticate user" rather than as a missing credential.
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
CHART_VERSION: ${{ steps.chart.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
PKG="dist/vantage-${CHART_VERSION}.tgz"
|
||||
test -f "$PKG"
|
||||
|
||||
# Checked explicitly, because the failure it prevents is a
|
||||
# 401 that looks like a permissions problem on the token that
|
||||
# was never sent.
|
||||
if [ -z "${REGISTRY_USER}" ] || [ -z "${REGISTRY_TOKEN}" ]; then
|
||||
echo "REGISTRY_USER or RELEASE_TOKEN is not set on this repository."
|
||||
echo "RELEASE_TOKEN needs the write:package scope to publish a chart."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "publishing to ${REGISTRY} as ${REGISTRY_USER}"
|
||||
|
||||
# --fail-with-body so an HTTP error is a failed step with the
|
||||
# server's explanation, rather than a green run that published
|
||||
# nothing. A repeated version is rejected by the registry;
|
||||
# that is the intended behaviour, not something to retry past.
|
||||
curl --fail-with-body -sS \
|
||||
--user "${REGISTRY_USER}:${REGISTRY_TOKEN}" \
|
||||
-X POST \
|
||||
--upload-file "$PKG" \
|
||||
"$REGISTRY"
|
||||
|
||||
echo "published vantage ${CHART_VERSION}"
|
||||
echo " helm repo add vantage ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/helm"
|
||||
echo " helm install vantage vantage/vantage --version ${CHART_VERSION}"
|
||||
@@ -77,15 +77,17 @@ jobs:
|
||||
# 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/|go\.work)'
|
||||
flag server '^(server/|shared/|proto/|default_steps/|go\.work)'
|
||||
flag sitesvc '^(sitesvc/|shared/|go\.work)'
|
||||
flag admin '^(admin/|shared/|go\.work)'
|
||||
|
||||
# The three Next images use their own directory as the build
|
||||
# context, so nothing outside it can affect them.
|
||||
# The three Next images and the docs site use their own
|
||||
# directory as the build context, so nothing outside it can
|
||||
# affect them.
|
||||
flag web '^web/'
|
||||
flag site '^site/'
|
||||
flag adminsite '^adminsite/'
|
||||
flag docsite '^docsite/'
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
@@ -93,6 +95,24 @@ jobs:
|
||||
docker login ${{ vars.DOCKER_HOST }} \
|
||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Verify the OpenAPI document is current
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc5
|
||||
cd server
|
||||
swag init --generalInfo cmd/main.go --dir ./,../shared \
|
||||
--output internal/api/docs --outputTypes json --v3.1
|
||||
mv -f internal/api/docs/swagger.json internal/api/docs/openapi.json
|
||||
git diff --exit-code internal/api/docs/openapi.json
|
||||
|
||||
- name: Build and push server image
|
||||
if: steps.changed.outputs.server == 'true'
|
||||
run: |
|
||||
@@ -106,7 +126,6 @@ jobs:
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f web/Dockerfile web/
|
||||
@@ -149,6 +168,18 @@ jobs:
|
||||
--build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \
|
||||
--build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \
|
||||
--build-arg NEXT_PUBLIC_SITE_URL="${{ vars.SITE_URL }}" \
|
||||
-t "$IMAGE" \
|
||||
-f adminsite/Dockerfile adminsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
- name: Build and push docsite image
|
||||
if: steps.changed.outputs.docsite == 'true'
|
||||
run: |
|
||||
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/docsite:latest"
|
||||
# DOCS_BASE_URL must match the proxy location that routes to
|
||||
# this container and the directory the image serves from.
|
||||
docker build \
|
||||
-t "$IMAGE" \
|
||||
-f docsite/Dockerfile docsite/
|
||||
docker push "$IMAGE"
|
||||
|
||||
+5
-1
@@ -3,6 +3,7 @@ dist
|
||||
build
|
||||
.env
|
||||
.env.bck
|
||||
.env.live
|
||||
docs/*
|
||||
!docs/superpowers/
|
||||
.superpowers
|
||||
@@ -11,4 +12,7 @@ installer/*.msi
|
||||
installer/nssm.zip
|
||||
installer/checksums-msi.txt
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
graphify-out
|
||||
docker-compose.live.yml
|
||||
.claude
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -8,15 +9,16 @@ import (
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// checkoutOptions serves everything the browser configurator needs to price a
|
||||
@@ -42,36 +44,67 @@ func checkoutOptions(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// createSelfHostedPlaceholder makes an instance row that exists only so a
|
||||
// checkout has something to put in custom_data. It carries no licence and is
|
||||
// flagged Placeholder until the customer pastes their install's real UUID. The
|
||||
// generated id is temporary; linking replaces the identity.
|
||||
func createSelfHostedPlaceholder(c *gin.Context) {
|
||||
// createSelfHostedCheckout prepares a paid self-hosted checkout against the
|
||||
// customer's REAL install UUID, and hands that id back for the checkout's
|
||||
// custom_data.
|
||||
//
|
||||
// A licence binds to the install's UUID, so the buyer must have a control plane
|
||||
// standing before they pay — the same precondition self-hosted Free already has.
|
||||
// That is what removes the placeholder: there is no temporary identity to
|
||||
// rewrite afterwards, the subscription's custom_data names the real instance
|
||||
// from the first event, and the webhook issues with no claim step.
|
||||
//
|
||||
// An id this account already owns is REUSED rather than refused: upgrading a
|
||||
// Free self-hosted install to a paid plan is the same purchase form, and
|
||||
// refusing it would mean the only route to Professional was to unlink first.
|
||||
// A UUID belonging to anyone else is still 409, from the unique index.
|
||||
func createSelfHostedCheckout(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.InstanceID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
instanceID := strings.TrimSpace(body.InstanceID)
|
||||
name := strings.TrimSpace(body.Name)
|
||||
ctx := c.Request.Context()
|
||||
inst := models.Instance{
|
||||
InstanceID: uuid.NewString(),
|
||||
AccountID: s.AccountID,
|
||||
Name: body.Name,
|
||||
Deployment: license.DeploymentSelfHosted,
|
||||
Status: models.StatusAwaitingLink,
|
||||
Placeholder: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
|
||||
var existing models.Instance
|
||||
err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&existing)
|
||||
switch {
|
||||
case err == nil:
|
||||
if existing.Deployment != license.DeploymentSelfHosted {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "that instance is a cloud instance; change its plan from its own page"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": existing.InstanceID})
|
||||
return
|
||||
case !errors.Is(err, mongo.ErrNoDocuments):
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
|
||||
return
|
||||
}
|
||||
inst, err := licensing.LinkInstance(ctx, s.AccountID, instanceID, name)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, licensing.ErrAlreadyLinked) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, IP: c.ClientIP()})
|
||||
Actor: s.Email, Action: "instance.checkout_started", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: "self-hosted", IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
|
||||
}
|
||||
|
||||
@@ -207,76 +240,6 @@ func updateEntitlement(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
|
||||
}
|
||||
|
||||
// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's
|
||||
// real install UUID, then issues.
|
||||
//
|
||||
// :id is the placeholder (generated at checkout, carried in the subscription's
|
||||
// custom_data); the body carries the UUID the install actually reports. The
|
||||
// licence must bind to that real UUID (spec 1 has no unbound licence), so the
|
||||
// placeholder row's identity is rewritten to it and the subscription re-pointed,
|
||||
// then billing issues from the recorded subscription. Linking and claiming stay
|
||||
// one call here because, unlike Free, the payment already happened.
|
||||
func claimPlaceholderLink(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !inst.Placeholder {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// The real UUID must be free across every account — the unique index on
|
||||
// instance_id is the tenant-isolation property, so refuse rather than collide.
|
||||
if n, _ := db.Admin("admin_instances").CountDocuments(ctx,
|
||||
bson.M{"instance_id": body.InstanceID}); n > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"})
|
||||
return
|
||||
}
|
||||
|
||||
placeholderID := inst.InstanceID
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{"$set": bson.M{
|
||||
"instance_id": body.InstanceID,
|
||||
"status": models.StatusActive,
|
||||
"placeholder": false,
|
||||
}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-point the subscription from the placeholder id to the real UUID so
|
||||
// billing.IssueForInstance (and every later webhook) finds it.
|
||||
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
|
||||
bson.M{"instance_id": placeholderID},
|
||||
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil {
|
||||
// The link stuck; issuance did not. The reconciler and a retry recover it,
|
||||
// and the customer is not blocked from linking. Surface it, do not roll back.
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"instance_id": body.InstanceID,
|
||||
"warning": "linked, but licence issuance is pending: " + err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.placeholder_linked",
|
||||
AccountID: inst.AccountID, Target: body.InstanceID,
|
||||
Detail: "from placeholder " + placeholderID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID})
|
||||
}
|
||||
|
||||
// billingPortal mints a Paddle customer-portal URL. The account must already
|
||||
// have a paddle_customer_id, which it learns from its first subscription webhook.
|
||||
func billingPortal(c *gin.Context) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// ownedInstance resolves an instance and confirms the session's account owns it.
|
||||
@@ -538,6 +540,185 @@ func claimFree(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, lic)
|
||||
}
|
||||
|
||||
// renameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// The control plane is written FIRST, because instances.slug carries the unique
|
||||
// index and that index is what actually settles a race between two accounts
|
||||
// reaching for the same name. Admin's own row follows; if that write fails the
|
||||
// control plane is put back, because HQ printing a host that is not the host is
|
||||
// worse than a failed rename.
|
||||
//
|
||||
// No licence is issued and Paddle is not called: a licence binds the instance
|
||||
// UUID, and a rename does not change it.
|
||||
func renameInstance(c *gin.Context) {
|
||||
inst, ok := ownedInstance(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
|
||||
return
|
||||
}
|
||||
if inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// The unwind and the audit write run on a context detached from the request.
|
||||
// The commonest reason the admin-side write fails at all is the caller
|
||||
// walking away, and an unwind sharing that context fails with it — leaving
|
||||
// the control plane renamed and admin's row not, which is the exact
|
||||
// divergence this handler is arranged to prevent.
|
||||
//
|
||||
// Only the cancellation is detached here; each deadline is derived at its use
|
||||
// site below. A deadline started before the forward work is a deadline the
|
||||
// unwind may never get to use — a control plane slow enough to make the admin
|
||||
// write fail is exactly the one that would have spent it already.
|
||||
detached := context.WithoutCancel(ctx)
|
||||
|
||||
// Claim the cooldown atomically BEFORE the control-plane call. Checking it
|
||||
// and then acting lets two parallel PUTs both pass the check and then
|
||||
// interleave their two-database writes, which ends with the two databases
|
||||
// disagreeing about the host — a worse outcome than either rename losing.
|
||||
// The conditional update IS the cooldown; there is no second reading of it.
|
||||
now := time.Now().UTC()
|
||||
var claimed models.Instance
|
||||
err := db.Admin("admin_instances").FindOneAndUpdate(ctx,
|
||||
bson.M{
|
||||
"instance_id": inst.InstanceID,
|
||||
"account_id": inst.AccountID,
|
||||
"$or": []bson.M{
|
||||
{"renamed_at": bson.M{"$exists": false}},
|
||||
{"renamed_at": bson.M{"$lte": now.Add(-models.RenameCooldown)}},
|
||||
},
|
||||
},
|
||||
bson.M{"$set": bson.M{"renamed_at": now}}).Decode(&claimed)
|
||||
if err != nil {
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
log.Printf("renameInstance: claiming the cooldown on %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
// No match means the cooldown is live or the row has gone; only a
|
||||
// re-read tells those apart, and they are different answers.
|
||||
var cur models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "account_id": inst.AccountID}).Decode(&cur); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if cur.RenamedAt != nil {
|
||||
until := cur.RenamedAt.Add(models.RenameCooldown)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
|
||||
"retry_after": until,
|
||||
})
|
||||
return
|
||||
}
|
||||
// The row is here and its cooldown is spent, yet the claim matched
|
||||
// nothing: it changed under us. Nothing has been written, so refuse
|
||||
// rather than guess which way.
|
||||
log.Printf("renameInstance: cooldown claim on %s matched nothing against an eligible row", inst.InstanceID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
// releaseClaim puts renamed_at back to whatever the claim overwrote — the
|
||||
// previous instant, or absent when there was none. Every failure past the
|
||||
// claim owes the customer their rename back.
|
||||
releaseClaim := func(after string) {
|
||||
undo := bson.M{"$unset": bson.M{"renamed_at": ""}}
|
||||
if claimed.RenamedAt != nil {
|
||||
undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}}
|
||||
}
|
||||
rcCtx, cancel := context.WithTimeout(detached, 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(rcCtx,
|
||||
bson.M{"instance_id": inst.InstanceID}, undo); err != nil {
|
||||
log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err)
|
||||
}
|
||||
}
|
||||
|
||||
renamed, prevName, prevSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
releaseClaim("a taken slug")
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
releaseClaim("a rejected name")
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
releaseClaim("a failed control-plane rename")
|
||||
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
// A matched count of zero is a silent version of the same failure: the
|
||||
// control plane moved and admin's row did not.
|
||||
res, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$set": bson.M{"name": renamed.Name, "slug": renamed.Slug}})
|
||||
if err == nil && res.MatchedCount == 0 {
|
||||
err = errors.New("admin_instances row matched nothing")
|
||||
}
|
||||
if err != nil {
|
||||
// The control plane's own previous values, not admin's copy: admin's may
|
||||
// be stale, and its slug is omitempty.
|
||||
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
|
||||
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
rbCancel()
|
||||
releaseClaim("a failed record write")
|
||||
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
if renamed.Slug == prevSlug {
|
||||
// The cooldown exists because a rename moves the DNS host; a cosmetic
|
||||
// edit that derives to the same slug moves nothing, so it should not
|
||||
// spend one. The claim is already written by this point — releasing it
|
||||
// is how that is expressed now the check is atomic.
|
||||
releaseClaim("a rename that did not move the host")
|
||||
}
|
||||
|
||||
s := auth.Current(c)
|
||||
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
audit.Write(auCtx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()})
|
||||
auCancel()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"instance_id": inst.InstanceID,
|
||||
"name": renamed.Name,
|
||||
"slug": renamed.Slug,
|
||||
// The same builder the licence emails use, rather than a second opinion
|
||||
// about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset.
|
||||
"login_url": loginURLFor(renamed.Slug),
|
||||
})
|
||||
}
|
||||
|
||||
// deliver sends a freshly issued licence where it needs to go. Cloud instances
|
||||
// are injected; self-hosted customers are emailed and can download.
|
||||
//
|
||||
|
||||
@@ -75,11 +75,18 @@ func Routes(cfg config.Config) http.Handler {
|
||||
cust.POST("/instances/:id/claim-free",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
claimFree)
|
||||
// Renaming moves the instance's DNS host, so it is owner-or-admin like
|
||||
// every other instance mutation. Cloud only; the handler refuses the rest.
|
||||
cust.PUT("/instances/:id/name",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
renameInstance)
|
||||
cust.GET("/instances/:id/entitlement", getEntitlement)
|
||||
cust.GET("/checkout/options", checkoutOptions)
|
||||
// Paid self-hosted: links (or reuses) the customer's real install UUID so
|
||||
// the checkout can name it. There is no placeholder and no claim step.
|
||||
cust.POST("/instances/self-hosted",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createSelfHostedPlaceholder)
|
||||
createSelfHostedCheckout)
|
||||
// Paid cloud: provisions a real instance the paid webhook then licenses.
|
||||
cust.POST("/instances/cloud",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
@@ -88,9 +95,6 @@ func Routes(cfg config.Config) http.Handler {
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateEntitlement)
|
||||
cust.POST("/billing/portal", billingPortal)
|
||||
cust.POST("/instances/:id/claim-link",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
claimPlaceholderLink)
|
||||
cust.GET("/instances/:id/license", getInstanceLicense)
|
||||
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
|
||||
cust.GET("/instances/:id/members", listInstanceMembers)
|
||||
@@ -119,6 +123,7 @@ func Routes(cfg config.Config) http.Handler {
|
||||
staff.GET("/subscriptions", staffListSubscriptions)
|
||||
staff.POST("/instances/:id/issue", staffIssue)
|
||||
staff.POST("/instances/:id/relink", staffRelink)
|
||||
staff.PUT("/instances/:id/name", staffRenameInstance)
|
||||
staff.GET("/licenses", staffListLicenses)
|
||||
staff.GET("/plans", staffListPlans)
|
||||
// Plans are keyed on the pair now, so the path is too. A single :tier
|
||||
|
||||
+114
-3
@@ -1,18 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -380,9 +385,12 @@ func staffListLicenses(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, lics)
|
||||
}
|
||||
|
||||
// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked
|
||||
// placeholders, so a customer who paid and got nothing is visible rather than
|
||||
// stuck in a support queue.
|
||||
// staffBillingHealth surfaces webhook handlers that failed and placeholders
|
||||
// still awaiting their instance, so a customer who paid and got nothing is
|
||||
// visible rather than stuck in a support queue.
|
||||
//
|
||||
// Placeholders are a cloud-only path now; any self-hosted row still listed here
|
||||
// predates the checkout change and needs issuing by hand.
|
||||
func staffBillingHealth(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
failed := []models.PaddleEvent{}
|
||||
@@ -615,3 +623,106 @@ func staffCreateAccountUser(c *gin.Context) {
|
||||
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
|
||||
c.JSON(http.StatusCreated, gin.H{"pending": true})
|
||||
}
|
||||
|
||||
// staffRenameInstance renames any instance, with no cooldown.
|
||||
//
|
||||
// It does NOT write renamed_at: a staff rename must not start the customer's
|
||||
// 24h clock, or fixing a name for someone locks them out of fixing it further.
|
||||
//
|
||||
// On self-hosted it changes admin's label only. There is no control-plane row to
|
||||
// write — the install is the customer's — and no slug, because self-hosted has
|
||||
// no tenant subdomain.
|
||||
//
|
||||
// A cloud placeholder is refused outright rather than relabelled: it has no
|
||||
// control-plane row yet, so a label-only rename here would be a name that the
|
||||
// instance never gets when provisioning finally derives its slug from the
|
||||
// checkout's name. The customer endpoint refuses it for the same reason.
|
||||
func staffRenameInstance(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if inst.Deployment == license.DeploymentCloud && inst.Placeholder {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
|
||||
return
|
||||
}
|
||||
|
||||
// The unwind and the audit write must survive the request being cancelled:
|
||||
// an unwind on a dead context leaves the two databases disagreeing, which is
|
||||
// the failure the unwind exists for.
|
||||
//
|
||||
// Only the cancellation is detached here; each deadline is derived at its use
|
||||
// site below. A deadline started before the forward work is a deadline the
|
||||
// unwind may never get to use — a control plane slow enough to make the admin
|
||||
// write fail is exactly the one that would have spent it already.
|
||||
detached := context.WithoutCancel(ctx)
|
||||
|
||||
set := bson.M{"name": name}
|
||||
slug := inst.Slug
|
||||
cloud := inst.Deployment == license.DeploymentCloud
|
||||
// The control plane's own previous values, not admin's copy: admin's may be
|
||||
// stale, and its slug is omitempty, so unwinding from it can write an empty
|
||||
// slug into instances.
|
||||
prevName, prevSlug := inst.Name, inst.Slug
|
||||
|
||||
if cloud {
|
||||
renamed, pName, pSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrSlugTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
|
||||
return
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
prevName, prevSlug = pName, pSlug
|
||||
slug = renamed.Slug
|
||||
set["slug"] = renamed.Slug
|
||||
}
|
||||
|
||||
// A matched count of zero is the same failure quietly: the control plane
|
||||
// moved and admin's row did not.
|
||||
res, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set})
|
||||
if err == nil && res.MatchedCount == 0 {
|
||||
err = errors.New("admin_instances row matched nothing")
|
||||
}
|
||||
if err != nil {
|
||||
if cloud {
|
||||
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
|
||||
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
rbCancel()
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
|
||||
audit.Write(auCtx, models.AuditEntry{
|
||||
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
|
||||
Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()})
|
||||
auCancel()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
@@ -67,9 +69,21 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
|
||||
}
|
||||
|
||||
// Resolve BEFORE recording. custom_data names whatever id the checkout was
|
||||
// opened against, and a relink since then has rewritten the instance's
|
||||
// identity and patched Paddle — but that patch is best-effort and any event
|
||||
// already in flight still carries the old id. Writing it straight through
|
||||
// would revert the subscription row and then fail to find the instance,
|
||||
// wedging every renewal.
|
||||
instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscription %s names unknown instance %s: %w",
|
||||
d.ID, d.CustomData.InstanceID, err)
|
||||
}
|
||||
|
||||
sub := models.Subscription{
|
||||
AccountID: d.CustomData.AccountID,
|
||||
InstanceID: d.CustomData.InstanceID,
|
||||
InstanceID: instanceID,
|
||||
PaddleSubscriptionID: d.ID,
|
||||
Tier: match.Tier,
|
||||
Term: match.Term,
|
||||
@@ -88,22 +102,20 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
|
||||
}
|
||||
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
|
||||
return fmt.Errorf("subscription %s names unknown instance %s: %w",
|
||||
d.ID, d.CustomData.InstanceID, err)
|
||||
}
|
||||
|
||||
// Placeholders are the payment-first path: the instance does not exist until
|
||||
// this confirmed-payment event. A cloud placeholder is provisioned here and
|
||||
// then issued (first term). A self-hosted placeholder has no UUID to bind to
|
||||
// until the customer pastes their install's — its subscription is recorded and
|
||||
// the link endpoint issues later.
|
||||
// A cloud placeholder is the payment-first path: the instance does not exist
|
||||
// until this confirmed-payment event, so it is provisioned here and then
|
||||
// issued (first term). Self-hosted has no placeholder — its checkout named
|
||||
// the install's real UUID — so it falls straight through to issuance.
|
||||
// An instance with no licence yet is a first purchase, not a change of plan.
|
||||
// Self-hosted reaches that state through an ordinary link, so the placeholder
|
||||
// flag no longer answers this on its own.
|
||||
reason := models.ReasonEntitlementChange
|
||||
if inst.CurrentLicense == "" {
|
||||
reason = models.ReasonNew
|
||||
}
|
||||
if inst.Placeholder {
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
return nil
|
||||
return fmt.Errorf("instance %s is a non-cloud placeholder, which no longer exists", inst.InstanceID)
|
||||
}
|
||||
provisioned, err := completeCloudPlaceholder(ctx, &inst)
|
||||
if err != nil {
|
||||
@@ -116,6 +128,27 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
return promoteAndIssue(ctx, &inst, match, reason)
|
||||
}
|
||||
|
||||
// resolveInstance finds the instance a webhook's custom_data names, following the
|
||||
// identity trail when the id is one a relink or a cloud placeholder's
|
||||
// provisioning has since replaced. It returns the instance's CURRENT id, which is the only id anything
|
||||
// else should be written against.
|
||||
func resolveInstance(ctx context.Context, customDataID string) (string, models.Instance, error) {
|
||||
var inst models.Instance
|
||||
err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": customDataID}).Decode(&inst)
|
||||
if err == nil {
|
||||
return inst.InstanceID, inst, nil
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return "", inst, err
|
||||
}
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"previous_instance_ids": customDataID}).Decode(&inst); err != nil {
|
||||
return "", inst, err
|
||||
}
|
||||
return inst.InstanceID, inst, nil
|
||||
}
|
||||
|
||||
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
|
||||
// licence from granted. This is the only promotion path other than the staff
|
||||
// grant, and it exists because a webhook is a confirmed payment.
|
||||
@@ -217,30 +250,6 @@ func handleCustomerUpdated(ctx context.Context, ev Event) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IssueForInstance issues from an instance's recorded subscription. Called when
|
||||
// a self-hosted customer finally links a placeholder they have already paid for.
|
||||
func IssueForInstance(ctx context.Context, instanceID string) error {
|
||||
var sub models.Subscription
|
||||
if err := db.Admin("subscriptions").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
|
||||
return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
|
||||
}
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]catalogue.Item, 0, len(sub.Items))
|
||||
for _, it := range sub.Items {
|
||||
items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
|
||||
}
|
||||
match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
|
||||
}
|
||||
|
||||
func toSubItems(items []catalogue.Item) []models.SubItem {
|
||||
out := make([]models.SubItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
@@ -280,9 +289,10 @@ func billingEmailFor(ctx context.Context, accountID string) string {
|
||||
|
||||
// instanceNameFor is a best-effort display name for an email subject.
|
||||
func instanceNameFor(ctx context.Context, instanceID string) string {
|
||||
var inst models.Instance
|
||||
if err := db.Admin("admin_instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" {
|
||||
// Alias-aware: a cancellation can name an id a relink has replaced, and "your instance"
|
||||
// in place of the name the customer chose reads like the wrong email.
|
||||
_, inst, err := resolveInstance(ctx, instanceID)
|
||||
if err != nil || inst.Name == "" {
|
||||
return "your instance"
|
||||
}
|
||||
return inst.Name
|
||||
|
||||
@@ -202,3 +202,23 @@ func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User,
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// RenameInstance changes a cloud instance's name and moves it to the slug that
|
||||
// name derives to.
|
||||
//
|
||||
// It writes `instances` and nothing else, so admin's control-plane write
|
||||
// boundary is unchanged. It issues no licence: a licence binds the instance
|
||||
// UUID, which a rename never touches.
|
||||
//
|
||||
// The previous name and slug come back with the result because they are what an
|
||||
// unwind must restore — admin's own copy can be stale, or slugless.
|
||||
func RenameInstance(ctx context.Context, instanceID, name string) (inst *sharedmodels.Instance, prevName, prevSlug string, err error) {
|
||||
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
|
||||
}
|
||||
|
||||
// RestoreInstanceIdentity puts an instance's previous name and slug back, for a
|
||||
// caller unwinding a rename whose admin-side write failed. Leaving the two
|
||||
// databases disagreeing would have HQ print a host that is not the host.
|
||||
func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error {
|
||||
return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -57,6 +59,48 @@ func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*mod
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
// RepointSubscriptions follows an instance identity rewrite: it moves every
|
||||
// subscription row from the old id to the new one, then rewrites Paddle's copy
|
||||
// of custom_data so future webhooks decode to the new id.
|
||||
//
|
||||
// The local rewrite is returned as an error — issuance reads the subscription
|
||||
// back, so a half-moved row is worth failing on. The Paddle patch only logs: the
|
||||
// customer must not be blocked from relinking by an outbound API
|
||||
// failure, and the caller has already recorded the old id in
|
||||
// previous_instance_ids, which is what makes the webhook path correct whether or
|
||||
// not the patch lands.
|
||||
func RepointSubscriptions(ctx context.Context, oldID, newID, accountID string) error {
|
||||
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
|
||||
bson.M{"instance_id": oldID},
|
||||
bson.M{"$set": bson.M{"instance_id": newID}}); err != nil {
|
||||
return fmt.Errorf("repoint %s -> %s: %w", oldID, newID, err)
|
||||
}
|
||||
|
||||
cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"instance_id": newID})
|
||||
if err != nil {
|
||||
log.Printf("repoint %s -> %s: read subscriptions: %v", oldID, newID, err)
|
||||
return nil
|
||||
}
|
||||
var subs []models.Subscription
|
||||
if err := cur.All(ctx, &subs); err != nil {
|
||||
log.Printf("repoint %s -> %s: decode subscriptions: %v", oldID, newID, err)
|
||||
return nil
|
||||
}
|
||||
for _, s := range subs {
|
||||
if s.PaddleSubscriptionID == "" {
|
||||
continue
|
||||
}
|
||||
// Paddle replaces the whole custom_data object on a PATCH, so account_id
|
||||
// is sent alongside rather than dropped.
|
||||
if err := paddle.Get().UpdateSubscriptionCustomData(ctx, s.PaddleSubscriptionID,
|
||||
map[string]string{"account_id": accountID, "instance_id": newID}); err != nil {
|
||||
log.Printf("repoint %s -> %s: patch custom_data on %s: %v",
|
||||
oldID, newID, s.PaddleSubscriptionID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Relink moves a licence to a rebuilt server's new UUID.
|
||||
//
|
||||
// The replacement covers the REMAINING term, not a fresh one — relinking is not
|
||||
@@ -96,13 +140,25 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": oldID},
|
||||
bson.M{"$set": bson.M{"instance_id": newID}, "$inc": bson.M{"relink_count": 1}}); err != nil {
|
||||
bson.M{
|
||||
"$set": bson.M{"instance_id": newID},
|
||||
"$inc": bson.M{"relink_count": 1},
|
||||
"$addToSet": bson.M{"previous_instance_ids": oldID},
|
||||
}); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return nil, ErrAlreadyLinked
|
||||
}
|
||||
return nil, fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
|
||||
// A relink rewrites the instance's identity, so two things have to follow it:
|
||||
// the subscription rows that named the old id, and Paddle's own copy of
|
||||
// custom_data. Without this a
|
||||
// renewal after a relink cannot find its instance and the term never extends.
|
||||
if err := RepointSubscriptions(ctx, oldID, newID, accountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actor := accountID
|
||||
if staff {
|
||||
actor = "staff"
|
||||
|
||||
@@ -77,6 +77,16 @@ func Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
if lic.ExpiresAt.Add(reapAfter).Before(now) {
|
||||
if _, err := db.Admin("admin_instances").DeleteOne(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
|
||||
log.Printf("lifecycle: delete instance %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
if _, err := db.Admin("licenses").DeleteMany(ctx, bson.M{"instance_id": inst.InstanceID}); err != nil {
|
||||
log.Printf("lifecycle: delete licenses for instance %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
|
||||
if due == "" {
|
||||
continue
|
||||
@@ -172,67 +182,6 @@ func runOnce(ctx context.Context) {
|
||||
if err := Run(runCtx); err != nil {
|
||||
log.Printf("lifecycle: %v", err)
|
||||
}
|
||||
sweepAwaitingLink(runCtx)
|
||||
}
|
||||
|
||||
// Awaiting-link reminder keys.
|
||||
const (
|
||||
noticeLink24 = "link_24"
|
||||
noticeLink72 = "link_72"
|
||||
)
|
||||
|
||||
// sweepAwaitingLink chases self-hosted instances that were paid for but never
|
||||
// linked: the subscription exists, the instance is still a placeholder. It
|
||||
// emails a reminder at 24h and again at 72h. The staff dashboard already flags
|
||||
// 48h; this is the active chasing on top of that. It never issues or deletes.
|
||||
func sweepAwaitingLink(ctx context.Context) {
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
|
||||
"deployment": license.DeploymentSelfHosted,
|
||||
"placeholder": true,
|
||||
"status": models.StatusAwaitingLink,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var instances []models.Instance
|
||||
if err := cur.All(ctx, &instances); err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, inst := range instances {
|
||||
// Only chase placeholders a customer has actually paid for.
|
||||
n, err := db.Admin("subscriptions").CountDocuments(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive})
|
||||
if err != nil || n == 0 {
|
||||
continue
|
||||
}
|
||||
if !mail.Enabled() {
|
||||
continue
|
||||
}
|
||||
to := accountEmail(ctx, inst.AccountID)
|
||||
if to == "" {
|
||||
continue
|
||||
}
|
||||
age := now.Sub(inst.CreatedAt)
|
||||
var due string
|
||||
if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink72) {
|
||||
due = noticeLink72
|
||||
} else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink24) {
|
||||
due = noticeLink24
|
||||
}
|
||||
if due == "" {
|
||||
continue
|
||||
}
|
||||
if err := mail.Default.SendLinkReminder(to, inst.Name); err != nil {
|
||||
log.Printf("lifecycle: link reminder %s for %s: %v", due, inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
|
||||
log.Printf("lifecycle: record link notice %s for %s: %v", due, inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func accountEmail(ctx context.Context, accountID string) string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
|
||||
@@ -210,6 +212,73 @@ func Backfill(ctx context.Context) error {
|
||||
if err := backfillEntitlements(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pass 6: instances whose identity was rewritten before previous_instance_ids
|
||||
// existed carry no trail, and Paddle's custom_data still names the id they
|
||||
// were rewritten FROM — so their next webhook resolves to nothing. Both
|
||||
// rewrites wrote an audit entry naming the old id, which is the only surviving
|
||||
// record of it, so reconstruct the trail from those.
|
||||
if err := backfillInstanceIDHistory(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfillInstanceIDHistory rebuilds previous_instance_ids from the audit entries
|
||||
// the two identity rewrites leave behind: a placeholder claim
|
||||
// ("instance.placeholder_linked", detail "from placeholder <id>") and a relink
|
||||
// ("instance.relinked", detail "was <id>").
|
||||
//
|
||||
// $addToSet is what makes it idempotent, and it also means a chain of relinks
|
||||
// accumulates rather than the last one winning. Entries are walked NEWEST first,
|
||||
// matching on the current id or an already-recovered one: an instance relinked
|
||||
// A→B→C answers to neither A nor B by the time this runs, so the C entry has to
|
||||
// record B before the B entry has anything to attach A to.
|
||||
func backfillInstanceIDHistory(ctx context.Context) error {
|
||||
prefixes := map[string]string{
|
||||
"instance.placeholder_linked": "from placeholder ",
|
||||
"instance.relinked": "was ",
|
||||
}
|
||||
actions := make(bson.A, 0, len(prefixes))
|
||||
for action := range prefixes {
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
cur, err := db.Admin("admin_audit").Find(ctx,
|
||||
bson.M{"action": bson.M{"$in": actions}},
|
||||
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var entries []AuditEntry
|
||||
if err := cur.All(ctx, &entries); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recorded := 0
|
||||
for _, e := range entries {
|
||||
prefix := prefixes[e.Action]
|
||||
if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) {
|
||||
continue
|
||||
}
|
||||
oldID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
|
||||
if oldID == "" || oldID == e.Target {
|
||||
continue
|
||||
}
|
||||
res, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"$or": bson.A{
|
||||
bson.M{"instance_id": e.Target},
|
||||
bson.M{"previous_instance_ids": e.Target},
|
||||
}},
|
||||
bson.M{"$addToSet": bson.M{"previous_instance_ids": oldID}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recorded += int(res.ModifiedCount)
|
||||
}
|
||||
if recorded > 0 {
|
||||
log.Printf("backfill: recovered %d instance id rewrites from the audit log", recorded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ func (r CatalogueRow) Priced(env string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
|
||||
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
|
||||
// server limit, and one row per feature key.
|
||||
//
|
||||
// The two Free plans get no rows at all, and that absence is what keeps Free
|
||||
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
|
||||
@@ -84,6 +85,7 @@ func SeedCatalogue(ctx context.Context) error {
|
||||
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
|
||||
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
|
||||
}
|
||||
for _, r := range rows {
|
||||
filter := bson.M{
|
||||
|
||||
@@ -111,6 +111,15 @@ const GracePeriod = 3 * 24 * time.Hour
|
||||
// second mechanism.
|
||||
const RenewWindow = 7 * 24 * time.Hour
|
||||
|
||||
// RenameCooldown is how long a customer must wait between renames of one
|
||||
// instance.
|
||||
//
|
||||
// A rename moves the instance's DNS host and invalidates every saved link to it,
|
||||
// so this exists to make that a considered act rather than a slider. Staff are
|
||||
// not subject to it: a support conversation about a name is already a human
|
||||
// deciding.
|
||||
const RenameCooldown = 24 * time.Hour
|
||||
|
||||
type Account struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
@@ -137,16 +146,30 @@ type Instance struct {
|
||||
Status string `bson:"status" json:"status"`
|
||||
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
|
||||
RelinkCount int `bson:"relink_count" json:"relink_count"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
// RenamedAt is when this instance last changed name, and backs the customer
|
||||
// rename cooldown. It is a pointer because absent means "never renamed"; a
|
||||
// zero time.Time would read as year 1 — an inert cooldown, but only by
|
||||
// accident. Staff renames deliberately leave it alone.
|
||||
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
|
||||
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
|
||||
// NoticesSent holds the lifecycle notice keys already emailed for the
|
||||
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
|
||||
// clears it, so the next term starts the sequence again. It is what stops a
|
||||
// restart re-sending a notice.
|
||||
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
|
||||
// Placeholder is true while a self-hosted instance row exists only so a
|
||||
// checkout has something to attach custom_data to, before the customer has
|
||||
// pasted their install's real UUID. Cleared when the instance is linked.
|
||||
// Placeholder is true while a paid CLOUD instance row exists only so a
|
||||
// checkout has something to attach custom_data to, before the confirmed
|
||||
// payment provisions it. Cleared once provisioned. Self-hosted has no
|
||||
// placeholder: its checkout names the install's real UUID.
|
||||
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
|
||||
// PreviousInstanceIDs is every id this row has carried before its current one.
|
||||
// A self-hosted row's identity is rewritten on each relink to a rebuilt
|
||||
// server, and Paddle keeps its own copy of custom_data written at checkout.
|
||||
// That copy
|
||||
// is patched on each rewrite, but the patch is best-effort and any event
|
||||
// already in flight still names an old id, so this is what lets a webhook
|
||||
// resolve to the right instance instead of erroring as unknown.
|
||||
PreviousInstanceIDs []string `bson:"previous_instance_ids,omitempty" json:"-"`
|
||||
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
|
||||
// remembered so the confirmed-payment webhook can provision the instance with
|
||||
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
|
||||
|
||||
@@ -25,6 +25,10 @@ type Client interface {
|
||||
// immediately by Paddle. This is the one outbound mutation, used when a
|
||||
// customer changes their server count or features on an existing plan.
|
||||
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
|
||||
// UpdateSubscriptionCustomData replaces a subscription's custom_data. Used
|
||||
// when a self-hosted instance is relinked to a rebuilt server: the checkout
|
||||
// attached the old id, and every later webhook must name the new one.
|
||||
UpdateSubscriptionCustomData(ctx context.Context, paddleSubscriptionID string, data map[string]string) error
|
||||
// PortalSession returns a customer-portal URL for managing billing.
|
||||
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
|
||||
// Env is "sandbox" or "production", the same value catalogue price lookups
|
||||
|
||||
@@ -99,6 +99,17 @@ func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// UpdateSubscriptionCustomData patches custom_data only. Paddle replaces the
|
||||
// whole object, so callers pass every key they want to keep.
|
||||
func (c *httpClient) UpdateSubscriptionCustomData(ctx context.Context, subID string, data map[string]string) error {
|
||||
if subID == "" {
|
||||
return fmt.Errorf("paddle: empty subscription id")
|
||||
}
|
||||
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, struct {
|
||||
CustomData map[string]string `json:"custom_data"`
|
||||
}{CustomData: data}, nil)
|
||||
}
|
||||
|
||||
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
|
||||
if customerID == "" {
|
||||
return "", fmt.Errorf("paddle: empty customer id")
|
||||
|
||||
@@ -26,6 +26,11 @@ ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN
|
||||
ARG NEXT_PUBLIC_PADDLE_ENV=sandbox
|
||||
ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV
|
||||
|
||||
# Marketing site origin. Signup lives there (/start), not here; empty renders no
|
||||
# link at all rather than one that 404s.
|
||||
ARG NEXT_PUBLIC_SITE_URL=
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
@@ -6,7 +6,10 @@ import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { ManageBillingButton } from "@/components/ManageBillingButton";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { TermSpark } from "@/components/TermBar";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { formatDate, licenceState } from "@/lib/format";
|
||||
|
||||
export default function BillingPage() {
|
||||
const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions });
|
||||
@@ -20,19 +23,29 @@ export default function BillingPage() {
|
||||
// Each subscription names the instance it pays for, because tier and term
|
||||
// are per-licence rather than per-account. Resolving the name here is the
|
||||
// difference between "professional · annual" and knowing which install that is.
|
||||
const nameFor = (instanceId?: string) =>
|
||||
account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
|
||||
const nameFor = (instanceId?: string) => account.data?.instances.find((i) => i.instance_id === instanceId)?.name;
|
||||
|
||||
/*
|
||||
* A subscription reports when the period ends but not when it began, so the
|
||||
* start is derived from the term. Only the two terms we actually sell are
|
||||
* handled — anything else returns null and the row falls back to the date
|
||||
* alone, because a bar drawn from a guessed span is worse than no bar.
|
||||
*/
|
||||
const periodStart = (end: string, term: string): string | null => {
|
||||
const months = /ann|year/i.test(term) ? 12 : /month/i.test(term) ? 1 : 0;
|
||||
if (!months) return null;
|
||||
const d = new Date(end);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setMonth(d.getMonth() - months);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Billing"
|
||||
subtitle="One subscription per instance — each carries its own tier and term."
|
||||
record={
|
||||
account.data
|
||||
? [{ key: "Billing", value: account.data.account.billing_email }]
|
||||
: undefined
|
||||
}
|
||||
subtitle="One subscription per instance each carries its own tier and term."
|
||||
record={account.data ? [{ key: "Billing", value: account.data.account.billing_email }] : undefined}
|
||||
/>
|
||||
|
||||
<PageFrame
|
||||
@@ -51,64 +64,63 @@ export default function BillingPage() {
|
||||
/>
|
||||
</RailCard>
|
||||
<RailCard title="Need a change?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Change a card, download an invoice or cancel from the billing
|
||||
portal. It covers every subscription on this account.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-2">Change a card, download an invoice or cancel from the billing portal. It covers every subscription on this account.</p>
|
||||
<ManageBillingButton />
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Anything else, email support.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@hostxtra.co.uk"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<p className="text-[0.82rem] text-ink-2">Anything else, email support.</p>
|
||||
<a href="mailto:support@hostxtra.co.uk" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
support@hostxtra.co.uk
|
||||
</a>
|
||||
</RailCard>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
You have no subscriptions. Cloud instances and self-hosted licences are
|
||||
both bought from the pricing page.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
<th className="px-4 py-2.5 font-normal">Instance</th>
|
||||
<th className="px-4 py-2.5 font-normal">Plan</th>
|
||||
<th className="px-4 py-2.5 font-normal">Term</th>
|
||||
<th className="px-4 py-2.5 font-normal">Status</th>
|
||||
<th className="px-4 py-2.5 font-normal">Renews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr
|
||||
key={s.subscription_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{nameFor(s.instance_id) ?? (
|
||||
<span className="text-ink-3">Not linked yet</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{s.term}</td>
|
||||
<td className="px-4 py-3">{s.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(s.current_period_end)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Panel title="Subscriptions" meta={rows.length ? `${rows.length}` : undefined} bodyless>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No subscriptions yet."
|
||||
body="Cloud instances and self-hosted licences are both bought from the plan page, and each one bills separately."
|
||||
/>
|
||||
) : (
|
||||
<Table stack>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Instance</TH>
|
||||
<TH>Plan</TH>
|
||||
<TH>Billing</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Renews</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((s) => {
|
||||
const start = periodStart(s.current_period_end, s.term);
|
||||
const name = nameFor(s.instance_id);
|
||||
return (
|
||||
<TR key={s.subscription_id}>
|
||||
<TD label="Instance">
|
||||
{name ?? <span className="text-ink-3">Not linked yet</span>}
|
||||
{name && <Sub>{s.instance_id?.slice(0, 8)}</Sub>}
|
||||
</TD>
|
||||
<TD label="Plan">{s.tier.replace("_", " ")}</TD>
|
||||
<TD label="Billing" className="text-ink-2">
|
||||
{s.term}
|
||||
</TD>
|
||||
<TD label="Status" className="text-ink-2">
|
||||
{s.status}
|
||||
</TD>
|
||||
<TD label="Renews">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{start && <TermSpark issuedAt={start} expiresAt={s.current_period_end} state={licenceState(s.current_period_end, true)} />}
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{formatDate(s.current_period_end)}</span>
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,64 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { API_BASE, ApiError, NotConnected, api, type License } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { LicenceDelivery } from "@/components/LicenceDelivery";
|
||||
import { MembersPanel } from "@/components/MembersPanel";
|
||||
import { RelinkPanel } from "@/components/RelinkPanel";
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { TermBar } from "@/components/TermBar";
|
||||
import { EmptyState, Note, Panel } from "@/components/Panel";
|
||||
import { PageFrame, RailCard } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/** One key/value row. The key is the same keyed idiom as everywhere else. */
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<dt className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{label}</dt>
|
||||
<dd className="m-0 text-[0.88rem] tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Every feature the product sells, granted or not.
|
||||
*
|
||||
* Listing only what is included answers "what do I have" but not "what am I
|
||||
* missing", which is the question someone on this screen is actually weighing
|
||||
* before they click Change plan. The absent ones are struck through rather than
|
||||
* omitted, so the comparison is on the page instead of in another tab.
|
||||
*/
|
||||
function Features({ granted }: { granted: string[] }) {
|
||||
const all = Object.keys(FEATURE_LABEL);
|
||||
// Anything the licence carries that this build does not know about is still
|
||||
// shown — the map degrades to the raw key, which is ugly but never wrong.
|
||||
const extras = granted.filter((f) => !all.includes(f));
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Features</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[...all, ...extras].map((f) => {
|
||||
const on = granted.includes(f);
|
||||
return (
|
||||
<span key={f} title={featureDesc(f) || undefined} className={on ? "rounded-sm border border-rule px-2 py-0.5 text-[0.78rem] text-ink-2" : "rounded-sm border border-rule-soft px-2 py-0.5 text-[0.78rem] text-ink-3 line-through decoration-ink-3/60"}>
|
||||
{featureLabel(f)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InstancePage() {
|
||||
const id = String(useParams().id);
|
||||
@@ -19,6 +67,10 @@ export default function InstancePage() {
|
||||
const qc = useQueryClient();
|
||||
const [relinkError, setRelinkError] = useState<string | undefined>();
|
||||
|
||||
// useSession is the app's one way to ask who the caller is — it shares the
|
||||
// ["me"] query, so this adds no request.
|
||||
const { session } = useSession();
|
||||
|
||||
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
const licence = useQuery({
|
||||
queryKey: ["license", id],
|
||||
@@ -28,12 +80,11 @@ export default function InstancePage() {
|
||||
|
||||
const relink = useMutation({
|
||||
mutationFn: (newId: string) => api.relink(id, newId),
|
||||
onSuccess: (lic) => {
|
||||
onSuccess: (lic: License) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
router.replace(`/instances/${lic.instance_id}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
|
||||
onError: (err) => setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
|
||||
});
|
||||
|
||||
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
@@ -50,117 +101,176 @@ export default function InstancePage() {
|
||||
const lic = licence.data;
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
const cloud = instance.deployment === "cloud";
|
||||
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
|
||||
const maxRelinks = account.data?.max_relinks ?? 3;
|
||||
const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title={instance.name || "Unnamed instance"}
|
||||
subtitle={`${cloud ? "Cloud" : "Self-hosted"}${
|
||||
instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""
|
||||
} · created ${formatDate(instance.created_at)}`}
|
||||
record={[
|
||||
{ key: "Instance", value: instance.instance_id, copy: true },
|
||||
...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : []),
|
||||
]}
|
||||
subtitle={`${cloud ? "Cloud" : "Self-hosted"} instance${instance.tier ? ` on ${instance.tier.replace("_", " ")}` : ""} · created ${formatDate(instance.created_at)}`}
|
||||
/*
|
||||
* The two things this screen is for, in the header rather than
|
||||
* hunted for further down. Download is self-hosted only: a cloud
|
||||
* licence is injected into the control plane directly and there
|
||||
* is nothing for the customer to do with the file.
|
||||
*/
|
||||
actions={
|
||||
<>
|
||||
{lic && !cloud && (
|
||||
<LinkButton variant="line" external href={api.licenseBlobUrl(instance.instance_id)}>
|
||||
Download licence
|
||||
</LinkButton>
|
||||
)}
|
||||
{lic && <LinkButton href="/purchase">Renew licence</LinkButton>}
|
||||
</>
|
||||
}
|
||||
record={[{ key: "Instance", value: instance.instance_id, copy: true }, ...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : [])]}
|
||||
status={<StatePill state={state} />}
|
||||
/>
|
||||
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
<RailCard title="Licence">
|
||||
{lic ? (
|
||||
<RailFacts
|
||||
rows={[
|
||||
{ label: "Tier", value: lic.tier.replace("_", " ") },
|
||||
{ label: "Issued", value: formatDate(lic.issued_at) },
|
||||
{ label: "Expires", value: formatDate(lic.expires_at) },
|
||||
{ label: "Reason", value: lic.reason },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
No licence issued yet.
|
||||
</p>
|
||||
)}
|
||||
host ? (
|
||||
<RailCard title="Console">
|
||||
<p className="text-[0.82rem] text-ink-2">Servers, workflows and monitors live in the instance itself.</p>
|
||||
<a href={`https://${host}`} className="inline-flex items-center justify-center gap-2 rounded border border-rule px-3 py-2 text-[0.84rem] font-semibold text-ink no-underline hover:border-accent hover:text-accent">
|
||||
Open {instance.name || "instance"} →
|
||||
</a>
|
||||
<p className="font-mono text-[0.72rem] text-ink-3">{host}</p>
|
||||
</RailCard>
|
||||
|
||||
{lic && (
|
||||
<RailCard title="Included">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Servers",
|
||||
value: limitLabel(lic.limits.max_servers),
|
||||
},
|
||||
{
|
||||
label: "Secret groups",
|
||||
value: limitLabel(lic.limits.max_secret_groups),
|
||||
},
|
||||
{
|
||||
label: "Channels",
|
||||
value: limitLabel(lic.limits.max_channels),
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
value: lic.features.join(", ") || "none",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
{!cloud && (
|
||||
<RailCard title="Moves">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Relinks used",
|
||||
value: `${instance.relink_count} of ${
|
||||
account.data?.max_relinks ?? 3
|
||||
}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Moving a licence to a different install counts as one.
|
||||
</p>
|
||||
</RailCard>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{cloud ? (
|
||||
<MembersPanel instanceId={instance.instance_id} />
|
||||
) : (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
Users for this install are managed inside it, in Settings → Instance. We do
|
||||
not have access to your own deployment.
|
||||
</p>
|
||||
{/*
|
||||
* The term leads. This screen is about one licence, and the rail
|
||||
* carried its issue and expiry dates as two lines of text —
|
||||
* which is the arithmetic this bar does for the reader.
|
||||
*/}
|
||||
{lic && (
|
||||
<Panel title="Licence" meta={`${lic.tier.replace("_", " ")} · ${cloud ? "Cloud" : "Self-hosted"}`}>
|
||||
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
|
||||
|
||||
{state === "warn" && <Note tone="warn">Inside 14 days of expiry. Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.</Note>}
|
||||
{state === "expired" && <Note tone="expired">A lapsed licence does not stop the control plane: agents carry on reporting and your servers keep their keys. It stops accepting changes, so nothing new can be deployed until this is renewed.</Note>}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{lic && !cloud && (
|
||||
<>
|
||||
<LicenceDelivery
|
||||
instanceId={instance.instance_id}
|
||||
blob={lic.blob ?? ""}
|
||||
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
|
||||
{/*
|
||||
* What the licence grants, on the screen about that licence.
|
||||
* These were four rows in a 320px rail card, which is where
|
||||
* facts go when nobody has decided they matter.
|
||||
*/}
|
||||
{lic && (
|
||||
<Panel
|
||||
title="Included"
|
||||
/* A panel-header action is a quiet link, not a second
|
||||
full-size button competing with the header's Renew. */
|
||||
actions={
|
||||
<Link href="/purchase" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
|
||||
Change plan →
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-x-8 gap-y-2.5 sm:grid-cols-2">
|
||||
<dl className="grid content-start gap-2.5">
|
||||
<Row label="Servers" value={limitLabel(lic.limits.max_servers)} />
|
||||
<Row label="Monitors" value={limitLabel(lic.limits.max_monitors)} />
|
||||
<Row label="Secret groups" value={limitLabel(lic.limits.max_secret_groups)} />
|
||||
</dl>
|
||||
<dl className="grid content-start gap-2.5">
|
||||
<Row label="Channels" value={limitLabel(lic.limits.max_channels)} />
|
||||
<Row label="Audit history" value={`${limitLabel(lic.limits.audit_retention_days)} days`} />
|
||||
<Row label="Issued for" value={lic.reason.replace("_", " ")} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<Features granted={lic.features} />
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* On a self-hosted instance the licence is the errand: someone
|
||||
* opens this page to fetch the blob and paste it. It sits
|
||||
* directly under the term, above the panels that only explain
|
||||
* things.
|
||||
*/}
|
||||
{lic && !cloud && <LicenceDelivery instanceId={instance.instance_id} blob={lic.blob ?? ""} downloadUrl={api.licenseBlobUrl(instance.instance_id)} />}
|
||||
|
||||
{cloud && <MembersPanel instanceId={instance.instance_id} />}
|
||||
|
||||
{/*
|
||||
* Address rather than "Rename": the panel is about where this
|
||||
* instance lives, and the rename is how you change it. Cloud
|
||||
* only — a self-hosted install has no tenant subdomain for us to
|
||||
* move.
|
||||
*/}
|
||||
{cloud && mayRename && (
|
||||
<Panel title="Address" meta={host ?? undefined}>
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
|
||||
one, so saved links and bookmarks to it stop working.
|
||||
</p>
|
||||
{/*
|
||||
* Keyed on the instance: this element stays mounted
|
||||
* across a navigation between two instance pages, so
|
||||
* without a key the success note and the typed name
|
||||
* from one instance surface on the next.
|
||||
*/}
|
||||
<RenamePanel
|
||||
key={instance.instance_id}
|
||||
movesHost
|
||||
currentName={instance.name}
|
||||
currentSlug={instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.renameInstance(instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
<RelinkPanel
|
||||
instanceId={instance.instance_id}
|
||||
used={instance.relink_count}
|
||||
max={account.data?.max_relinks ?? 3}
|
||||
error={relinkError}
|
||||
onRelink={(newId) => relink.mutate(newId)}
|
||||
/>
|
||||
</>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* "Moves" rather than "Relinks": the count is rationed, so the
|
||||
* headline is how many are left, and the panel explains what
|
||||
* spends one. Cloud instances cannot move — we own the host —
|
||||
* so the panel is absent rather than present and refusing.
|
||||
*/}
|
||||
{!cloud && (
|
||||
<Panel title="Moves" meta={`${Math.max(0, maxRelinks - instance.relink_count)} of ${maxRelinks} left`}>
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
A licence binds to one install. Rebuilding the host, or moving to different hardware, needs a replacement licence bound to the new ID —
|
||||
that is a move, and it covers the rest of your current term.
|
||||
</p>
|
||||
<RelinkPanel instanceId={instance.instance_id} used={instance.relink_count} max={maxRelinks} error={relinkError} onRelink={(newId) => relink.mutate(newId)} />
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* A panel holding one sentence has not decided what it is for.
|
||||
* For a self-hosted install the useful content is not "we don't
|
||||
* do this" but where the thing they came looking for actually
|
||||
* lives — and why the people on their HQ account are not it.
|
||||
*/}
|
||||
{!cloud && (
|
||||
<Panel title="Who can sign in" meta="Managed in your install">
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
You run this deployment, so its users live inside it rather than here. Add and remove them in the instance’s own settings.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
People on your Vantage HQ account can see billing and this licence. That is separate from who can sign in to the instance, and granting
|
||||
one never grants the other.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!lic && (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
No licence has been issued for this instance yet.
|
||||
</p>
|
||||
<Panel bodyless>
|
||||
<EmptyState title="No licence issued yet." body="A licence binds to one install, so it is issued once this instance is linked to the ID its install reports." action={<LinkButton href="/purchase">Get a licence</LinkButton>} />
|
||||
</Panel>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function LinkForm({
|
||||
onLinked,
|
||||
claimId,
|
||||
}: {
|
||||
onLinked: (instanceId: string) => void;
|
||||
// The PAID placeholder awaiting its real install UUID: claim it in place. The
|
||||
// name was chosen at checkout, so it is not asked for again. Self-hosted Free
|
||||
// is created on the purchase page instead, not here.
|
||||
claimId: string;
|
||||
}) {
|
||||
const [id, setId] = useState("");
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = id.trim();
|
||||
|
||||
// Checked here so a typo costs nothing and the message is instant.
|
||||
if (!UUID_RE.test(value)) {
|
||||
setError(
|
||||
"That does not look like an instance ID. It should look like the example below.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const inst = await api.claimLink(claimId, value);
|
||||
onLinked(inst.instance_id);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not link that instance. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="grid gap-4" noValidate>
|
||||
<Field
|
||||
label="Instance ID"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
error={error}
|
||||
hint={
|
||||
<>
|
||||
Find this on your install’s <code>Settings → Licence</code> page, or on
|
||||
the setup screen just after you first sign in. It looks like{" "}
|
||||
<code>6a0fe3f0-49d2-4aa1-967c-a3094b200b5d</code>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button type="submit" disabled={busy} className="justify-self-start">
|
||||
{busy ? "Linking…" : "Link and issue licence"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { LinkForm } from "./LinkForm";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function LinkPage() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
// This page only claims a PAID placeholder's real install UUID. Self-hosted
|
||||
// Free is created on the purchase page, so with no placeholder to claim there
|
||||
// is nothing to do here — send them there.
|
||||
const claimId = useSearchParams().get("claim") ?? undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!claimId) router.replace("/purchase");
|
||||
}, [claimId, router]);
|
||||
|
||||
if (!claimId) return null;
|
||||
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Link an install"
|
||||
subtitle="Every licence is tied to one install, so we need its ID before we can issue yours. Paste it below and your licence is ready on the next screen."
|
||||
/>
|
||||
<LinkForm
|
||||
claimId={claimId}
|
||||
onLinked={(instanceId) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
// Straight to the download, not back to a list: the licence is
|
||||
// the thing they came for.
|
||||
router.push(`/instances/${instanceId}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { RequireKind } from "@/lib/session";
|
||||
import { AppBar, type NavLink } from "@/components/AppBar";
|
||||
|
||||
/*
|
||||
* Three destinations, not five. Settings moved into the account menu — it is
|
||||
* your password, not a place — and Instances went with it, because Overview
|
||||
* Three destinations, not five. Settings moved into the account menu it is
|
||||
* your password, not a place and Instances went with it, because Overview
|
||||
* already lists them and a second door to the same room is just a second thing
|
||||
* to keep in sync. Linking an install is an action, so it is a button on
|
||||
* Overview rather than a permanent nav entry.
|
||||
@@ -22,11 +22,7 @@ function AccountName() {
|
||||
// Shares the ["account"] key with Overview, so this costs no extra request.
|
||||
const { data } = useQuery({ queryKey: ["account"], queryFn: api.account });
|
||||
if (!data) return null;
|
||||
return (
|
||||
<span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">
|
||||
{data.account.name}
|
||||
</span>
|
||||
);
|
||||
return <span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">{data.account.name}</span>;
|
||||
}
|
||||
|
||||
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { API_BASE, NotConnected, api, type License } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { InstanceRecord } from "@/components/InstanceRecord";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
@@ -34,116 +35,129 @@ export default function OverviewPage() {
|
||||
|
||||
const live = data.instances.filter((i) => i.status !== "deleted");
|
||||
|
||||
// Work the customer has to do, gathered across every instance. This is the
|
||||
// only account-level view of it — each record only knows about itself.
|
||||
/*
|
||||
* Work the customer has to do, gathered across every instance. This is the
|
||||
* only account-level view of it — each record only knows about itself.
|
||||
*
|
||||
* Each item carries the way out of it. It used to be a list of sentences in
|
||||
* the rail, which told someone their licence was expiring and then made
|
||||
* them go and find the instance that owned it; the fix for every one of
|
||||
* these is one click, so the click belongs on the row.
|
||||
*/
|
||||
const attention = live.flatMap((i) => {
|
||||
const lic = byInstance.get(i.instance_id);
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
const name = i.name || "An instance";
|
||||
|
||||
if (state === "none")
|
||||
return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
|
||||
if (state === "expired")
|
||||
return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
|
||||
if (state === "warn")
|
||||
return [
|
||||
{
|
||||
id: i.instance_id,
|
||||
text: `${i.name} expires`,
|
||||
note: `${daysRemaining(lic!.expires_at)}d`,
|
||||
text: `${name} has no licence yet`,
|
||||
note: "Pick a plan and we will issue a licence for this install.",
|
||||
href: "/purchase",
|
||||
action: "Get a licence",
|
||||
tag: "",
|
||||
},
|
||||
];
|
||||
if (state === "expired")
|
||||
return [
|
||||
{
|
||||
id: i.instance_id,
|
||||
text: `${name} has expired`,
|
||||
note: "Servers keep running and agents keep their keys, but changes are disabled until you renew.",
|
||||
href: `/instances/${i.instance_id}`,
|
||||
action: "Renew",
|
||||
tag: "now",
|
||||
},
|
||||
];
|
||||
if (state === "warn") {
|
||||
const d = daysRemaining(lic!.expires_at);
|
||||
return [
|
||||
{
|
||||
id: i.instance_id,
|
||||
text: `${name} expires in ${d} ${d === 1 ? "day" : "days"}`,
|
||||
note: "Renewing extends the term from the current expiry, so nothing is lost by renewing early.",
|
||||
href: `/instances/${i.instance_id}`,
|
||||
action: "Renew",
|
||||
tag: `${d}d`,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
const subtitle =
|
||||
live.length === 0
|
||||
? "Nothing here yet."
|
||||
: `${live.length} ${live.length === 1 ? "instance" : "instances"}${
|
||||
attention.length ? ` · ${attention.length} needing attention` : " · all licensed"
|
||||
}`;
|
||||
live.length === 0 ? "Nothing here yet." : `${live.length} ${live.length === 1 ? "instance" : "instances"}${attention.length ? ` · ${attention.length} needing attention` : " · all licensed"}`;
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
live.length > 0 ? (
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
actions={live.length > 0 ? <LinkButton href="/purchase">Buy a plan</LinkButton> : undefined}
|
||||
record={[
|
||||
{ key: "Account", value: data.account.account_id, copy: true },
|
||||
{ key: "Billing", value: data.account.billing_email },
|
||||
]}
|
||||
status={
|
||||
attention.length === 0 && live.length > 0 ? (
|
||||
<StatePill state="valid" />
|
||||
) : undefined
|
||||
}
|
||||
status={attention.length === 0 && live.length > 0 ? <StatePill state="valid" /> : undefined}
|
||||
/>
|
||||
|
||||
{live.length === 0 ? (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">No instances yet</h2>
|
||||
<p className="text-ink-2">
|
||||
Create a free cloud instance and we host it, with your licence applied
|
||||
automatically. Or run Vantage on your own server and get its licence — free
|
||||
or paid — from the purchase page.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
/*
|
||||
* An empty screen is an invitation to act, and the two ways in
|
||||
* are genuinely different products — we host it, or you do. One
|
||||
* button and a paragraph explaining the other option made the
|
||||
* self-hosted path read as an afterthought, which it is not.
|
||||
*/
|
||||
<div className="grid gap-4 rounded border border-rule bg-panel p-6">
|
||||
<div className="grid gap-2">
|
||||
<h2 className="text-xl">No instances yet</h2>
|
||||
<p className="max-w-[52ch] text-ink-2">An instance is one Vantage control plane. Start a hosted one in about a minute, or license an install you run yourself.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid content-start gap-2 rounded border border-rule p-4">
|
||||
<h3 className="text-[1.05rem]">Cloud</h3>
|
||||
<p className="text-[0.82rem] text-ink-2">We host it, on a subdomain of vantage.hostxtra.co.uk, with the licence applied for you.</p>
|
||||
<div className="pt-1">
|
||||
<LinkButton href="/purchase">Create a cloud instance</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid content-start gap-2 rounded border border-rule p-4">
|
||||
<h3 className="text-[1.05rem]">Self-hosted</h3>
|
||||
<p className="text-[0.82rem] text-ink-2">You host it. Get the licence here, then paste your install’s ID to bind it.</p>
|
||||
<div className="pt-1">
|
||||
<LinkButton variant="line" href="/purchase">
|
||||
License my own install
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[0.78rem] text-ink-3">The Free tier covers 5 servers and needs no card.</p>
|
||||
</div>
|
||||
) : (
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
{attention.length > 0 && (
|
||||
<RailCard title="Needs you" count={attention.length}>
|
||||
<ul className="grid gap-2">
|
||||
{attention.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span>{a.text}</span>
|
||||
{a.note && (
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
|
||||
{a.note}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
<RailCard title="Your team" count={people.data?.length}>
|
||||
<ul className="grid gap-2">
|
||||
{(people.data ?? []).slice(0, 5).map((p) => (
|
||||
<li
|
||||
key={p.user_id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<li key={p.user_id} className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2">
|
||||
<span className="truncate">{p.email}</span>
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
{p.account_role}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">{p.account_role}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{pending > 0 && (
|
||||
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
|
||||
{pending} {pending === 1 ? "invitation" : "invitations"} not
|
||||
accepted yet
|
||||
{pending} {pending === 1 ? "invitation" : "invitations"} not accepted yet
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/users"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/users" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Manage people
|
||||
</Link>
|
||||
</RailCard>
|
||||
@@ -151,7 +165,7 @@ export default function OverviewPage() {
|
||||
{/*
|
||||
* Account-level facts only. Tier, limits and renewal date
|
||||
* belong to a licence, and a licence belongs to one
|
||||
* instance — an account holding a Free cloud instance and
|
||||
* instance an account holding a Free cloud instance and
|
||||
* a Professional self-hosted one has no single plan.
|
||||
*/}
|
||||
<RailCard title="Account">
|
||||
@@ -165,29 +179,46 @@ export default function OverviewPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/billing" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Billing history
|
||||
</Link>
|
||||
</RailCard>
|
||||
|
||||
<RailCard title="Running Vantage yourself?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Get a licence for your own install — free or paid — from the
|
||||
purchase page. It keeps its own users.
|
||||
</p>
|
||||
<Link
|
||||
href="/purchase"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<p className="text-[0.82rem] text-ink-2">Get a licence for your own install free or paid from the purchase page. It keeps its own users.</p>
|
||||
<Link href="/purchase" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Get a licence
|
||||
</Link>
|
||||
</RailCard>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/*
|
||||
* First in the main column, not in the rail. This is the
|
||||
* reason the page is open; the rail is for things that are
|
||||
* merely true. It disappears entirely when there is nothing
|
||||
* in it rather than saying "all clear", which is a line
|
||||
* nobody needs to read twice a week.
|
||||
*/}
|
||||
{attention.length > 0 && (
|
||||
<Panel title="Needs you" meta={`${attention.length} ${attention.length === 1 ? "item" : "items"}`} bodyless>
|
||||
<ul className="grid">
|
||||
{attention.map((a) => (
|
||||
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3 last:border-b-0">
|
||||
<div className="grid min-w-0 gap-0.5">
|
||||
<span className="flex items-center gap-2 text-[0.9rem] font-semibold">
|
||||
{a.text}
|
||||
{a.tag && <span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-warn">{a.tag}</span>}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-ink-3">{a.note}</span>
|
||||
</div>
|
||||
<LinkButton href={a.href}>{a.action}</LinkButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{live.map((i, n) => {
|
||||
const lic = byInstance.get(i.instance_id);
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
@@ -199,12 +230,7 @@ export default function OverviewPage() {
|
||||
// Open when it is the only one, or when it is the
|
||||
// first thing that needs a decision. A saved toggle
|
||||
// beats this from then on.
|
||||
defaultOpen={
|
||||
live.length === 1 ||
|
||||
(state !== "valid" &&
|
||||
attention[0]?.id === i.instance_id) ||
|
||||
(attention.length === 0 && n === 0)
|
||||
}
|
||||
defaultOpen={live.length === 1 || (state !== "valid" && attention[0]?.id === i.instance_id) || (attention.length === 0 && n === 0)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,25 +6,16 @@ import Link from "next/link";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
|
||||
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
|
||||
import { featureDesc, featureLabel } from "@/lib/features";
|
||||
|
||||
/* Tiers in the order a customer reads them, cheapest first. */
|
||||
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/* Human labels for feature keys. The catalogue names them by key; this is the
|
||||
* one place the customer-facing wording lives. */
|
||||
const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
};
|
||||
const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
};
|
||||
function featureLabel(key: string) {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
/* Feature wording lives in lib/features.ts, shared with the staff
|
||||
* configurator. It was duplicated here and there, and the two copies had
|
||||
* already drifted. */
|
||||
|
||||
interface Choice {
|
||||
tier: Tier;
|
||||
@@ -88,7 +79,7 @@ export function PurchaseForm() {
|
||||
|
||||
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
|
||||
|
||||
// Real line items for the current configuration — the same builder the
|
||||
// Real line items for the current configuration the same builder the
|
||||
// checkout uses, so the summary can never disagree with the overlay.
|
||||
const items = useMemo(() => (options ? lineItemsFor(options, choice, dep) : []), [options, choice, dep]);
|
||||
|
||||
@@ -151,10 +142,13 @@ export function PurchaseForm() {
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the licence."),
|
||||
});
|
||||
|
||||
// Self-hosted checkout names the install's REAL UUID, so the instance is
|
||||
// linked (or an already-owned one reused) before Paddle opens. The webhook
|
||||
// then issues straight onto it — there is no placeholder to claim afterwards.
|
||||
const startCheckout = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = name.trim();
|
||||
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHosted(trimmed);
|
||||
const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHostedCheckout(uuid.trim(), trimmed);
|
||||
return r.instance_id;
|
||||
},
|
||||
onSuccess: async (instanceId) => {
|
||||
@@ -168,12 +162,6 @@ export function PurchaseForm() {
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start checkout."),
|
||||
});
|
||||
|
||||
const claim = useMutation({
|
||||
mutationFn: () => api.claimLink(pending!.instanceId, uuid.trim()),
|
||||
onSuccess: () => router.push("/"),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link the install."),
|
||||
});
|
||||
|
||||
if (optionsQ.isLoading || account.isLoading) {
|
||||
return <p className="text-ink-3">Loading plans…</p>;
|
||||
}
|
||||
@@ -294,7 +282,7 @@ export function PurchaseForm() {
|
||||
{featureKeys.map((key) => {
|
||||
const st = featureStateFor(plan, rows, options.env, choice.term, key);
|
||||
return (
|
||||
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
|
||||
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
|
||||
{st === "included" ? (
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
|
||||
) : st === "absent" ? (
|
||||
@@ -317,12 +305,13 @@ export function PurchaseForm() {
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{selfHostedFree && (
|
||||
<Block n={3} label="Your install">
|
||||
{dep === "self_hosted" && (
|
||||
<Block n={paid ? 4 : 3} label="Your install">
|
||||
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
Install Vantage on your own server first, then paste the instance ID it
|
||||
reports. We register it and issue your Free licence — nothing to pay.
|
||||
{paid
|
||||
? "Every licence binds to one install, so stand your control plane up first and paste the instance ID it reports. We attach it to your account now and the licence lands the moment payment clears. Already have an instance here? Paste its ID to upgrade it."
|
||||
: "Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence — nothing to pay."}
|
||||
</p>
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
|
||||
@@ -332,10 +321,7 @@ export function PurchaseForm() {
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
|
||||
/>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
Find this on your install’s Settings → Licence page, or the setup
|
||||
screen just after first sign-in.
|
||||
</span>
|
||||
<span className="text-[0.72rem] text-ink-3">Find this on your install’s Settings → Licence page, or the setup screen just after first sign-in.</span>
|
||||
</label>
|
||||
</div>
|
||||
</Block>
|
||||
@@ -393,7 +379,7 @@ export function PurchaseForm() {
|
||||
) : (
|
||||
<Cta
|
||||
label={startCheckout.isPending ? "Starting…" : "Continue to payment"}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending || (dep === "self_hosted" && !UUID_RE.test(uuid.trim()))}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
startCheckout.mutate();
|
||||
@@ -402,28 +388,13 @@ export function PurchaseForm() {
|
||||
))}
|
||||
|
||||
{/* Phase B: after the checkout has been opened. */}
|
||||
{pending?.deployment === "self_hosted" && (
|
||||
{pending && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Once payment clears, paste the instance ID your install reports (Settings → Licence) to receive your licence.</p>
|
||||
<input
|
||||
value={uuid}
|
||||
onChange={(e) => setUuid(e.target.value)}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
|
||||
/>
|
||||
<Cta
|
||||
label={claim.isPending ? "Linking…" : "Link and issue licence"}
|
||||
disabled={!uuid.trim() || claim.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
claim.mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pending?.deployment === "cloud" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears — no further steps.</p>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
{pending.deployment === "cloud"
|
||||
? "Your instance is being set up. Its licence appears the moment payment clears — no further steps."
|
||||
: "Your install is attached to this account. Its licence appears the moment payment clears — no further steps."}
|
||||
</p>
|
||||
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
|
||||
Go to your instance
|
||||
</Link>
|
||||
@@ -554,7 +525,7 @@ function TierCard({
|
||||
return (
|
||||
<FeatureLine key={key} on={st !== "absent"}>
|
||||
{featureLabel(key)}
|
||||
{st === "included" ? " — included" : st === "addon" ? " — add-on" : " — not available"}
|
||||
{st === "included" ? " included" : st === "addon" ? " add-on" : " not available"}
|
||||
</FeatureLine>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Button, controlClass } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
import { PageFrame, RailCard } from "@/components/PageFrame";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -24,6 +24,7 @@ export function InvitePanel() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<AccountRole>("member");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
|
||||
const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
|
||||
@@ -46,8 +47,14 @@ export function InvitePanel() {
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api.removeAccountUser(id),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
onSuccess: () => {
|
||||
setConfirming(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (e) => {
|
||||
setConfirming(null);
|
||||
fail(e);
|
||||
},
|
||||
});
|
||||
|
||||
if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
@@ -82,11 +89,7 @@ export function InvitePanel() {
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Account role
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as AccountRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as AccountRole)} className={controlClass()}>
|
||||
{assignable.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
@@ -175,22 +178,46 @@ export function InvitePanel() {
|
||||
: "Invitation pending"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{canManage && !isSelf && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Remove ${u.email}? They lose access to every instance on this account.`,
|
||||
)
|
||||
)
|
||||
remove.mutate(u.user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{canManage &&
|
||||
!isSelf &&
|
||||
/*
|
||||
* Inline rather than window.confirm(): removing
|
||||
* someone here revokes them from every instance
|
||||
* on the account, which is more than the word
|
||||
* "Remove" beside one row implies, and the
|
||||
* browser dialog cannot show the consequence
|
||||
* where the eye already is.
|
||||
*/
|
||||
(confirming === u.user_id ? (
|
||||
<span className="inline-flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-[0.82rem] text-ink-2">
|
||||
Removes access to every instance.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline disabled:opacity-50"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(u.user_id)}
|
||||
>
|
||||
{remove.isPending ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] text-ink-2 underline"
|
||||
onClick={() => setConfirming(null)}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => setConfirming(u.user_id)}
|
||||
>
|
||||
Remove<span className="sr-only"> {u.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Field } from "@/components/Field";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { controlClass } from "@/components/Button";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export function AccountSearch() {
|
||||
const [q, setQ] = useState("");
|
||||
@@ -14,56 +16,65 @@ export function AccountSearch() {
|
||||
queryFn: () => api.staff.accounts(q || undefined),
|
||||
});
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Field
|
||||
label="Search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
hint="Name, email, Paddle customer ID, or an instance UUID."
|
||||
/>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Account</th>
|
||||
<th className="px-4 py-2.5">Billing email</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).map((a) => (
|
||||
<tr
|
||||
key={a.account_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/accounts/${a.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Panel>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Search</span>
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Name, email, ctm_… or an instance UUID"
|
||||
className={controlClass()}
|
||||
/>
|
||||
</label>
|
||||
</Panel>
|
||||
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Account</TH>
|
||||
<TH>Billing email</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Created</TH>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((a) => (
|
||||
<TR key={a.account_id}>
|
||||
<TD>
|
||||
<Link href={`/staff/accounts/${a.account_id}`} className="font-semibold text-accent no-underline hover:underline">
|
||||
{a.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[0.82rem]">
|
||||
{a.billing_email}
|
||||
</td>
|
||||
<td className="px-4 py-3">{a.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(a.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
<Sub>
|
||||
<span className="font-mono">{a.account_id}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD className="font-mono text-[0.82rem] text-ink-2">{a.billing_email}</TD>
|
||||
<TD className="text-ink-2">{a.status}</TD>
|
||||
<TD className="font-mono tabular-nums text-ink-2">{formatDate(a.created_at)}</TD>
|
||||
<TD numeric>
|
||||
<Link href={`/staff/accounts/${a.account_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
|
||||
Open
|
||||
</Link>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!isFetching && (data ?? []).length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">
|
||||
No account matches that. Try the instance UUID from the customer’s
|
||||
email.
|
||||
</p>
|
||||
</TBody>
|
||||
</Table>
|
||||
|
||||
{!isFetching && rows.length === 0 && (
|
||||
<EmptyState
|
||||
title={q ? "No account matches that." : "No accounts yet."}
|
||||
body={q ? "Try the instance UUID from the customer's email — it resolves to the account that owns it." : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export default function AccountDetailPage() {
|
||||
const id = String(useParams().id);
|
||||
@@ -17,7 +19,7 @@ export default function AccountDetailPage() {
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<div className="grid gap-5">
|
||||
<PageHeader
|
||||
back={{ href: "/staff/accounts", label: "Accounts" }}
|
||||
title={data.account.name}
|
||||
@@ -25,92 +27,130 @@ export default function AccountDetailPage() {
|
||||
record={[
|
||||
{ key: "Account", value: data.account.account_id, copy: true },
|
||||
{ key: "Status", value: data.account.status },
|
||||
...(data.account.paddle_customer_id
|
||||
? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }]
|
||||
: []),
|
||||
...(data.account.paddle_customer_id ? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<Panel title="Instances">
|
||||
<ul className="grid gap-2">
|
||||
{data.instances.map((i) => (
|
||||
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
|
||||
<Link
|
||||
href={`/staff/instances/${i.instance_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
{i.name || i.instance_id}
|
||||
</Link>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{i.deployment} · {i.tier ?? "no tier"} · {i.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.instances.length === 0 && <li className="text-ink-3">None.</li>}
|
||||
</ul>
|
||||
{/*
|
||||
* Four lists of "thing · thing · thing" became four tables. Each row
|
||||
* held three or four separate facts run into one string with
|
||||
* middots, which cannot be scanned down a column — and a staff
|
||||
* screen is read by scanning down a column.
|
||||
*/}
|
||||
<Panel title="Instances" meta={String(data.instances.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Instance</TH>
|
||||
<TH>Deployment</TH>
|
||||
<TH>Tier</TH>
|
||||
<TH>Status</TH>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.instances.map((i) => (
|
||||
<TR key={i.instance_id}>
|
||||
<TD>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="font-semibold text-accent no-underline hover:underline">
|
||||
{i.name || "Unnamed instance"}
|
||||
</Link>
|
||||
<Sub>
|
||||
<span className="font-mono">{i.instance_id.slice(0, 8)}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD className="text-ink-2">{i.deployment === "cloud" ? "Cloud" : "Self-hosted"}</TD>
|
||||
<TD className="text-ink-2">{i.tier?.replace("_", " ") ?? "—"}</TD>
|
||||
<TD className="text-ink-2">{i.status}</TD>
|
||||
<TD numeric>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
|
||||
Open
|
||||
</Link>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.instances.length === 0 && <EmptyState title="No instances on this account." body="They have signed up but not created or linked anything yet." />}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Subscriptions">
|
||||
<ul className="grid gap-2">
|
||||
{data.subscriptions.map((s) => (
|
||||
<li key={s.subscription_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span>
|
||||
{s.tier.replace("_", " ")} · {s.term}
|
||||
</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{s.status} · renews {formatDate(s.current_period_end)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.subscriptions.length === 0 && <li className="text-ink-3">None.</li>}
|
||||
</ul>
|
||||
<Panel title="Subscriptions" meta={String(data.subscriptions.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Tier</TH>
|
||||
<TH>Billing</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Renews</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.subscriptions.map((s) => (
|
||||
<TR key={s.subscription_id}>
|
||||
<TD>{s.tier.replace("_", " ")}</TD>
|
||||
<TD className="text-ink-2">{s.term}</TD>
|
||||
<TD className="text-ink-2">{s.status}</TD>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(s.current_period_end)}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.subscriptions.length === 0 && <EmptyState title="No subscriptions." body="Everything on this account is Free, or nothing has been bought yet." />}
|
||||
</Panel>
|
||||
|
||||
<Panel title="People">
|
||||
<ul className="grid gap-2">
|
||||
{data.users.map((u) => (
|
||||
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="font-mono text-[0.82rem]">{u.email}</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{u.verified_at
|
||||
? `verified ${formatDate(u.verified_at)}`
|
||||
: "not verified"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.users.length === 0 && (
|
||||
<li className="text-ink-3">
|
||||
None — this is a cloud account, so its people sign in with their
|
||||
control-plane details.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<Panel title="People" meta={String(data.users.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Email</TH>
|
||||
<TH>Role</TH>
|
||||
<TH>Verified</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.users.map((u) => (
|
||||
<TR key={u.user_id}>
|
||||
<TD className="font-mono text-[0.82rem]">{u.email}</TD>
|
||||
<TD className="text-ink-2">{u.account_role}</TD>
|
||||
<TD className="text-ink-2">
|
||||
{u.verified_at ? (
|
||||
<span className="font-mono tabular-nums">{formatDate(u.verified_at)}</span>
|
||||
) : (
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-warn">Not verified</span>
|
||||
)}
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.users.length === 0 && (
|
||||
<EmptyState title="No HQ people on this account." body="This is a cloud account, so its people sign in with their control-plane details instead." />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Audit">
|
||||
<ul className="grid gap-1 font-mono text-[0.82rem]">
|
||||
{data.audit.map((e, n) => (
|
||||
<li key={n} className="flex flex-wrap justify-between gap-2 text-ink-2">
|
||||
<span>
|
||||
{e.action} · {e.actor}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
|
||||
</ul>
|
||||
<Panel title="Audit" meta="Newest first" bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Date</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.audit.map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(e.created_at)}</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.audit.length === 0 && <EmptyState title="Nothing recorded against this account yet." />}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,16 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate, formatStamp } from "@/lib/format";
|
||||
import { Field } from "@/components/Field";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { controlClass } from "@/components/Button";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export default function AuditPage() {
|
||||
const [filter, setFilter] = useState("");
|
||||
const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
|
||||
|
||||
const rows = (data ?? []).filter((e) =>
|
||||
filter
|
||||
? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
const rows = (data ?? []).filter((e) => (filter ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase()) : true));
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
@@ -24,30 +22,59 @@ export default function AuditPage() {
|
||||
subtitle="Every mutating action across every account, newest first."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<Field
|
||||
label="Filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
hint="Action, actor or target."
|
||||
/>
|
||||
<ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
|
||||
{rows.map((e, n) => (
|
||||
<li
|
||||
key={n}
|
||||
className="grid gap-1 border-b border-rule-soft pb-2 last:border-0 sm:grid-cols-[11rem_1fr]"
|
||||
>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)} {formatStamp(e.created_at)}
|
||||
</span>
|
||||
<span className="text-ink-2">
|
||||
<b className="text-ink">{e.action}</b> · {e.actor}
|
||||
{e.target && ` · ${e.target}`}
|
||||
{e.detail && ` · ${e.detail}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing matches that.</li>}
|
||||
</ul>
|
||||
|
||||
<Panel>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Filter</span>
|
||||
<input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Action, actor or target"
|
||||
className={controlClass()}
|
||||
/>
|
||||
</label>
|
||||
</Panel>
|
||||
|
||||
{/*
|
||||
* A table, not a list of mono sentences joined by middots. Every row
|
||||
* held five separate facts run together into one string, so nothing
|
||||
* could be scanned down a column — which is the only way anyone
|
||||
* reads an audit log looking for "who did this".
|
||||
*/}
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Time</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
<TH>Detail</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
{formatStamp(e.created_at)}
|
||||
<Sub>{formatDate(e.created_at)}</Sub>
|
||||
</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
<TD className="text-[0.82rem] text-ink-3">{e.detail ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{rows.length === 0 && (
|
||||
<EmptyState
|
||||
title={filter ? "Nothing matches that." : "No actions recorded yet."}
|
||||
body={filter ? "Clear the filter to see the whole log." : "Every licence issued, relinked or reaped is written here as it happens."}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { PageFrame } from "@/components/PageFrame";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { api, type CatalogueRow, type Term } from "@/lib/api";
|
||||
|
||||
const ENVS = ["sandbox", "production"] as const;
|
||||
@@ -69,35 +71,27 @@ export default function CataloguePage() {
|
||||
{isLoading ? (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading…</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4">
|
||||
{groups.map((g) => {
|
||||
const [deployment, tier] = g.split("/");
|
||||
const terms = termsFor(deployment);
|
||||
return (
|
||||
<section key={g} className="space-y-2">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "}
|
||||
{tier}
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-left text-ink-3">
|
||||
<th className="py-2 pr-3 font-normal">Component</th>
|
||||
<Panel key={g} title={`${deployment === "cloud" ? "Cloud" : "Self-Hosted"} ${tier}`} meta={terms.join(" · ")} bodyless>
|
||||
<Table className="min-w-[42rem]">
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Component</TH>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<th
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3 font-normal"
|
||||
>
|
||||
<TH key={`${env}-${t}`}>
|
||||
{env} / {t}
|
||||
</th>
|
||||
</TH>
|
||||
)),
|
||||
)}
|
||||
<th className="py-2 font-normal" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows
|
||||
.filter(
|
||||
(r) =>
|
||||
@@ -111,19 +105,11 @@ export default function CataloguePage() {
|
||||
JSON.stringify(ids) !==
|
||||
JSON.stringify(r.price_ids ?? {});
|
||||
return (
|
||||
<tr
|
||||
key={k}
|
||||
className="border-b border-rule/60"
|
||||
>
|
||||
<td className="py-2 pr-3 text-ink">
|
||||
{componentLabel(r)}
|
||||
</td>
|
||||
<TR key={k}>
|
||||
<TD className="text-ink">{componentLabel(r)}</TD>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<td
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3"
|
||||
>
|
||||
<TD key={`${env}-${t}`}>
|
||||
<input
|
||||
value={
|
||||
ids[env]?.[t] ?? ""
|
||||
@@ -145,12 +131,12 @@ export default function CataloguePage() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink"
|
||||
className="w-40 rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.78rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
</TD>
|
||||
)),
|
||||
)}
|
||||
<td className="py-2">
|
||||
<TD numeric>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
@@ -162,18 +148,17 @@ export default function CataloguePage() {
|
||||
price_ids: ids,
|
||||
})
|
||||
}
|
||||
className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
|
||||
className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</TBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,7 @@ import { ApiError, api, type Tier } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export function IssuePanel({
|
||||
instanceId,
|
||||
}: {
|
||||
instanceId: string;
|
||||
}) {
|
||||
export function IssuePanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [tier, setTier] = useState<Tier>("professional");
|
||||
const [term, setTerm] = useState("annual");
|
||||
@@ -35,28 +31,16 @@ export function IssuePanel({
|
||||
<section className="grid gap-4 border-t border-rule-soft pt-5">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Tier
|
||||
</span>
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Tier</span>
|
||||
<select value={tier} onChange={(e) => setTier(e.target.value as Tier)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Term
|
||||
</span>
|
||||
<select
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
>
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Term</span>
|
||||
<select value={term} onChange={(e) => setTerm(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2">
|
||||
<option value="annual">Annual</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
@@ -67,18 +51,8 @@ export function IssuePanel({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field
|
||||
label="Relink to instance ID"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
hint="Staff relinks are not capped — the customer cap exists to put you in the loop."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() => relink.mutate()}
|
||||
disabled={!newId.trim()}
|
||||
>
|
||||
<Field label="Relink to instance ID" value={newId} onChange={(e) => setNewId(e.target.value)} hint="Staff relinks are not capped the customer cap exists to put you in the loop." />
|
||||
<Button type="button" variant="line" onClick={() => relink.mutate()} disabled={!newId.trim()}>
|
||||
Relink
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,17 @@ import clsx from "clsx";
|
||||
import { api, type Deployment, type InjectionState } from "@/lib/api";
|
||||
import { Ledger } from "@/components/Ledger";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TermBar } from "@/components/TermBar";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { licenceState } from "@/lib/format";
|
||||
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
|
||||
import { IssuePanel } from "./IssuePanel";
|
||||
import { RenamePanel } from "@/components/RenamePanel";
|
||||
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
current: { label: "Control plane holds the current licence", tone: "text-valid" },
|
||||
stale: {
|
||||
label: "Control plane holds an older blob — the reconciler will repair it",
|
||||
label: "Control plane holds an older blob the reconciler will repair it",
|
||||
tone: "text-warn",
|
||||
},
|
||||
missing: { label: "No matching instance in the control plane", tone: "text-expired" },
|
||||
@@ -23,6 +27,7 @@ const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
|
||||
export default function StaffInstancePage() {
|
||||
const id = String(useParams().id);
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["staff-instance", id],
|
||||
queryFn: () => api.staff.instance(id),
|
||||
@@ -32,6 +37,13 @@ export default function StaffInstancePage() {
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
|
||||
const current = data.licenses.find((l) => !l.superseded_by);
|
||||
// A cloud placeholder has no control-plane row yet, so there is no host to
|
||||
// move and nothing to rename — the panel's wording and its control are both
|
||||
// read from this one answer rather than from the deployment alone, which is
|
||||
// how they came to contradict each other.
|
||||
const movesHost = data.instance.deployment === "cloud" && !data.instance.placeholder;
|
||||
const cloudPlaceholder = data.instance.deployment === "cloud" && data.instance.placeholder;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
@@ -44,42 +56,70 @@ export default function StaffInstancePage() {
|
||||
title={data.instance.name || data.instance.instance_id}
|
||||
subtitle={
|
||||
<>
|
||||
<Link
|
||||
href={`/staff/accounts/${data.account.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Link href={`/staff/accounts/${data.account.account_id}`} className="text-accent underline">
|
||||
{data.account.name || data.account.account_id}
|
||||
</Link>
|
||||
<span className="text-ink-3">
|
||||
{" "}
|
||||
· {data.instance.deployment} · {data.instance.status}
|
||||
{data.instance.relink_count > 0 &&
|
||||
` · ${data.instance.relink_count} relinks this term`}
|
||||
{data.instance.relink_count > 0 && ` · ${data.instance.relink_count} relinks this term`}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
record={[
|
||||
{ key: "Instance", value: data.instance.instance_id, copy: true },
|
||||
...(data.instance.slug
|
||||
? [{ key: "Slug", value: data.instance.slug }]
|
||||
: []),
|
||||
]}
|
||||
record={[{ key: "Instance", value: data.instance.instance_id, copy: true }, ...(data.instance.slug ? [{ key: "Slug", value: data.instance.slug }] : [])]}
|
||||
/>
|
||||
{data.injection.applicable && inj && (
|
||||
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
|
||||
)}
|
||||
{data.injection.applicable && inj && <p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>}
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">Licence history</h2>
|
||||
{/*
|
||||
* The live licence is the one nothing has superseded, which is the
|
||||
* record's own statement of the fact — not its position in the
|
||||
* array, which is the server's ordering and not a guarantee.
|
||||
*/}
|
||||
{current && (
|
||||
<Panel title="Current licence" meta={current.license_id}>
|
||||
<TermBar issuedAt={current.issued_at} expiresAt={current.expires_at} state={licenceState(current.expires_at, true)} className="max-w-xl" />
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Licence history" meta="Append-only">
|
||||
<Ledger licenses={data.licenses} />
|
||||
<IssuePanel instanceId={data.instance.instance_id} />
|
||||
</section>
|
||||
</Panel>
|
||||
|
||||
<EntitlementSection
|
||||
instanceId={data.instance.instance_id}
|
||||
deployment={data.instance.deployment}
|
||||
/>
|
||||
{/*
|
||||
* Staff rename has no cooldown and does not start the customer's:
|
||||
* fixing a name on someone's behalf must not spend their next 24
|
||||
* hours.
|
||||
*/}
|
||||
<Panel title="Name" meta={movesHost ? "Moves the address" : "Label only"}>
|
||||
{cloudPlaceholder ? (
|
||||
// The API refuses this with a 409, so offering the control
|
||||
// would only be a form that cannot succeed.
|
||||
<p className="text-[0.85rem] text-ink-3">
|
||||
This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that.
|
||||
</p>
|
||||
) : (
|
||||
/*
|
||||
* Keyed on the instance so a success note cannot follow staff
|
||||
* from one instance page to the next — the element stays
|
||||
* mounted across that navigation.
|
||||
*/
|
||||
<RenamePanel
|
||||
key={data.instance.instance_id}
|
||||
movesHost={movesHost}
|
||||
currentName={data.instance.name}
|
||||
currentSlug={data.instance.slug ?? ""}
|
||||
onRename={async (name) => {
|
||||
const res = await api.staff.renameInstance(data.instance.instance_id, name);
|
||||
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,8 +154,7 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (grant: boolean) =>
|
||||
api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
mutationFn: (grant: boolean) => api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
|
||||
@@ -127,38 +166,21 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
<header className="mb-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
What this instance is allowed. A licence is signed from{" "}
|
||||
<em>granted</em>, never from <em>desired</em>.
|
||||
What this instance is allowed. A licence is signed from <em>granted</em>, never from <em>desired</em>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{ent && data?.pending && (
|
||||
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
|
||||
Pending change — currently granted {ent.granted.servers} servers,
|
||||
configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at
|
||||
? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}`
|
||||
: ""}
|
||||
.
|
||||
Pending change currently granted {ent.granted.servers} servers, configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at ? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PlanConfigurator
|
||||
deployment={deployment}
|
||||
value={choice}
|
||||
plans={plans}
|
||||
catalogue={catalogue}
|
||||
onChange={setDraft}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
<PlanConfigurator deployment={deployment} value={choice} plans={plans} catalogue={catalogue} onChange={setDraft} disabled={save.isPending} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate(false)}
|
||||
className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40"
|
||||
>
|
||||
<button type="button" disabled={save.isPending} onClick={() => save.mutate(false)} className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40">
|
||||
Save as configured
|
||||
</button>
|
||||
<button
|
||||
@@ -170,15 +192,8 @@ function EntitlementSection({ instanceId, deployment }: { instanceId: string; de
|
||||
Save and grant
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">
|
||||
Granting takes effect on the next licence issued. It does not issue
|
||||
one.
|
||||
</p>
|
||||
{save.error && (
|
||||
<p className="mt-2 text-[0.82rem] text-expired">
|
||||
{String((save.error as Error).message)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">Granting takes effect on the next licence issued. It does not issue one.</p>
|
||||
{save.error && <p className="mt-2 text-[0.82rem] text-expired">{String((save.error as Error).message)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { api, type Tier } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { formatDate, licenceState } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { controlClass } from "@/components/Button";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { TermSpark } from "@/components/TermBar";
|
||||
|
||||
const SELECT = controlClass("w-auto");
|
||||
|
||||
export default function LicensesPage() {
|
||||
const [tier, setTier] = useState<"" | Tier>("");
|
||||
@@ -14,9 +20,8 @@ export default function LicensesPage() {
|
||||
|
||||
// Filtered here rather than server-side: the endpoint caps at 500 rows and
|
||||
// staff are narrowing a list they can already see.
|
||||
const rows = (data ?? []).filter(
|
||||
(l) => (!tier || l.tier === tier) && (!reason || l.reason === reason),
|
||||
);
|
||||
const rows = (data ?? []).filter((l) => (!tier || l.tier === tier) && (!reason || l.reason === reason));
|
||||
const filtered = Boolean(tier || reason);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
@@ -25,78 +30,93 @@ export default function LicensesPage() {
|
||||
subtitle="Append-only. A renewal writes a new row and supersedes the old one."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier | "")}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by tier"
|
||||
>
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
<option value="self_hosted">Self-Hosted (legacy)</option>
|
||||
</select>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by reason"
|
||||
>
|
||||
<option value="">All reasons</option>
|
||||
<option value="new">New</option>
|
||||
<option value="renewal">Renewal</option>
|
||||
<option value="tier_change">Tier change</option>
|
||||
<option value="relink">Relink</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Issued</th>
|
||||
<th className="px-4 py-2.5">Instance</th>
|
||||
<th className="px-4 py-2.5">Tier</th>
|
||||
<th className="px-4 py-2.5">Reason</th>
|
||||
<th className="px-4 py-2.5">Expires</th>
|
||||
<th className="px-4 py-2.5">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((l) => (
|
||||
<tr
|
||||
key={l.license_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.issued_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/instances/${l.instance_id}`}
|
||||
className="font-mono text-[0.82rem] text-accent underline"
|
||||
>
|
||||
{l.instance_id.slice(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.expires_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-3">
|
||||
{l.superseded_by ? "superseded" : "current"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Panel>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Tier</span>
|
||||
<select value={tier} onChange={(e) => setTier(e.target.value as Tier | "")} className={SELECT} aria-label="Filter by tier">
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
<option value="self_hosted">Self-Hosted (legacy)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Reason</span>
|
||||
<select value={reason} onChange={(e) => setReason(e.target.value)} className={SELECT} aria-label="Filter by reason">
|
||||
<option value="">All reasons</option>
|
||||
<option value="new">New</option>
|
||||
<option value="renewal">Renewal</option>
|
||||
<option value="tier_change">Tier change</option>
|
||||
<option value="relink">Relink</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Issued</TH>
|
||||
<TH>Instance</TH>
|
||||
<TH>Tier</TH>
|
||||
<TH>Reason</TH>
|
||||
<TH>Term</TH>
|
||||
<TH>Expires</TH>
|
||||
<TH>State</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
// A superseded row is overprinted rather than hidden:
|
||||
// it is the only record of why an instance stopped
|
||||
// working on a given date.
|
||||
<TR key={l.license_id} className={dead ? "text-ink-3" : undefined}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.issued_at)}</TD>
|
||||
<TD>
|
||||
<Link href={`/staff/instances/${l.instance_id}`} className="font-mono text-[0.82rem] text-accent no-underline hover:underline">
|
||||
{l.instance_id.slice(0, 8)}
|
||||
</Link>
|
||||
<Sub>
|
||||
<span className="font-mono">{l.license_id.slice(0, 8)}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD>{l.tier.replace("_", " ")}</TD>
|
||||
<TD className="text-ink-2">{l.reason.replace("_", " ")}</TD>
|
||||
{/* A superseded row's term is not a countdown to
|
||||
anything — it ended when its successor was
|
||||
issued, so drawing a bar would invite a
|
||||
comparison that means nothing. */}
|
||||
<TD>
|
||||
{dead ? (
|
||||
<span className="font-mono text-[0.72rem] text-ink-3">—</span>
|
||||
) : (
|
||||
<TermSpark issuedAt={l.issued_at} expiresAt={l.expires_at} state={licenceState(l.expires_at, true)} />
|
||||
)}
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.expires_at)}</TD>
|
||||
<TD>
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3">{dead ? "superseded" : "current"}</span>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
|
||||
{rows.length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">No licences match those filters.</p>
|
||||
<EmptyState
|
||||
title={filtered ? "No licences match those filters." : "No licences issued yet."}
|
||||
body={filtered ? "Clear a filter to widen the search." : "Every issue, renewal and relink writes a row here."}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Queue } from "@/components/Queue";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { daysRemaining, formatStamp } from "@/lib/format";
|
||||
@@ -35,9 +37,7 @@ export default function StaffDashboard() {
|
||||
|
||||
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
const stale = (unlinked.data ?? []).filter(
|
||||
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
|
||||
);
|
||||
const stale = (unlinked.data ?? []).filter((i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48);
|
||||
|
||||
const failed = injection.data?.count ?? 0;
|
||||
const instances = allInstances.data ?? [];
|
||||
@@ -46,11 +46,7 @@ export default function StaffDashboard() {
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Operations"
|
||||
subtitle={
|
||||
failed > 0
|
||||
? "Injection failures come first — those instances are paying for a licence they have not received."
|
||||
: "Nothing failing. Queues below are routine chasing."
|
||||
}
|
||||
subtitle={failed > 0 ? "Injection failures come first those instances are paying for a licence they have not received." : "Nothing failing. Queues below are routine chasing."}
|
||||
actions={
|
||||
<LinkButton variant="line" href="/staff/accounts">
|
||||
Find an account
|
||||
@@ -68,9 +64,7 @@ export default function StaffDashboard() {
|
||||
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/instances/${i.instance_id}`,
|
||||
meta: i.inject_failed_at
|
||||
? new Date(i.inject_failed_at).toISOString().slice(11, 16)
|
||||
: "",
|
||||
meta: i.inject_failed_at ? new Date(i.inject_failed_at).toISOString().slice(11, 16) : "",
|
||||
}))}
|
||||
/>
|
||||
<Queue
|
||||
@@ -117,8 +111,7 @@ export default function StaffDashboard() {
|
||||
},
|
||||
{
|
||||
label: "Self-hosted",
|
||||
value: instances.filter((i) => i.deployment === "self_hosted")
|
||||
.length,
|
||||
value: instances.filter((i) => i.deployment === "self_hosted").length,
|
||||
},
|
||||
{
|
||||
label: "Awaiting link",
|
||||
@@ -126,62 +119,43 @@ export default function StaffDashboard() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/staff/licenses"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
<Link href="/staff/licenses" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
All licences
|
||||
</Link>
|
||||
</RailCard>
|
||||
}
|
||||
>
|
||||
<section className="overflow-hidden rounded border border-rule bg-panel">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[0.95rem]">Recent activity</h2>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
Every licence issued, relinked or reaped, newest first.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/audit"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Full audit
|
||||
<Panel
|
||||
title="Recent activity"
|
||||
actions={
|
||||
<Link href="/staff/audit" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
|
||||
Full audit →
|
||||
</Link>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-[0.86rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule-soft font-mono text-[0.64rem] uppercase tracking-[0.11em] text-ink-3">
|
||||
<th className="px-4 py-2 font-normal">Time</th>
|
||||
<th className="px-4 py-2 font-normal">Action</th>
|
||||
<th className="px-4 py-2 font-normal">Target</th>
|
||||
<th className="px-4 py-2 font-normal">Actor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<tr key={n} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">
|
||||
{new Date(e.created_at).toISOString().slice(11, 16)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{e.action}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
|
||||
</tr>
|
||||
))}
|
||||
{audit.data?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-ink-3">
|
||||
Nothing yet today.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
bodyless
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Time</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{audit.data?.length === 0 && <EmptyState title="Nothing yet today." body="Every licence issued, relinked or reaped appears here as it happens." />}
|
||||
</Panel>
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Panel } from "@/components/Panel";
|
||||
|
||||
const SUPPORT_LEVELS = [
|
||||
{ value: "community", label: "Community" },
|
||||
@@ -26,26 +27,16 @@ const LIMIT_FIELDS = [
|
||||
* 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;
|
||||
}) {
|
||||
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="space-y-3">
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
{f.label}
|
||||
</span>
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
@@ -58,23 +49,17 @@ function AllowanceForm({
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">
|
||||
−1 is unlimited
|
||||
</span>
|
||||
<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>
|
||||
<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 px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
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}>
|
||||
@@ -86,24 +71,17 @@ function AllowanceForm({
|
||||
</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 })}
|
||||
/>
|
||||
<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>
|
||||
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => onSave(draft)}
|
||||
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
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>
|
||||
@@ -131,26 +109,20 @@ export default function PlansPage() {
|
||||
onError: () => setSaving(null),
|
||||
});
|
||||
|
||||
const original = plans.data?.find(
|
||||
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
|
||||
);
|
||||
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."
|
||||
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
|
||||
}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
|
||||
onConfirm={() => {
|
||||
setSaving(`${draft.deployment}/${draft.tier}`);
|
||||
save.mutate(draft);
|
||||
@@ -160,31 +132,19 @@ export default function PlansPage() {
|
||||
)}
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="space-y-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
|
||||
</h2>
|
||||
<section key={deployment} className="grid gap-3">
|
||||
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<article
|
||||
<Panel
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
className="rounded-lg border border-rule bg-panel p-4"
|
||||
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}
|
||||
>
|
||||
<header className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">
|
||||
{p.name}
|
||||
</h3>
|
||||
<span className="font-mono text-[0.75rem] text-ink-3">
|
||||
{p.deployment}/{p.tier}
|
||||
</span>
|
||||
</header>
|
||||
<AllowanceForm
|
||||
plan={p}
|
||||
saving={saving === `${p.deployment}/${p.tier}`}
|
||||
onSave={(next: Plan) => setDraft(next)}
|
||||
/>
|
||||
</article>
|
||||
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
import { AuthMessage, AuthShell } from "@/components/AuthShell";
|
||||
|
||||
function AcceptForm() {
|
||||
const token = useSearchParams().get("token") ?? "";
|
||||
@@ -17,60 +17,65 @@ function AcceptForm() {
|
||||
const accept = useMutation({
|
||||
mutationFn: () => api.acceptInvite(token, password),
|
||||
onSuccess: () => setDone(true),
|
||||
onError: (e) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
|
||||
onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
|
||||
});
|
||||
|
||||
if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
|
||||
if (!token)
|
||||
return (
|
||||
<AuthMessage
|
||||
title="That link is incomplete"
|
||||
body="It is missing its token. Use the link in the invitation exactly as sent — some mail clients cut long links in half."
|
||||
action={{ href: "/login", label: "Go to sign in" }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (done)
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<h1 className="text-3xl">You're in</h1>
|
||||
<p className="text-ink-2">Sign in with your email address and new password.</p>
|
||||
<Link href="/login" className="font-semibold text-accent underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
<AuthMessage
|
||||
title="You're in"
|
||||
body="Sign in with your email address and the password you just set."
|
||||
action={{ href: "/login", label: "Sign in" }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid max-w-md gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
accept.mutate();
|
||||
}}
|
||||
<AuthShell
|
||||
title="Choose a password"
|
||||
lede="You have been invited to a Vantage HQ account."
|
||||
footnote="Nobody who invited you can see this password, and it is never sent to them."
|
||||
>
|
||||
<h1 className="text-3xl">Choose a password</h1>
|
||||
<p className="text-ink-2">
|
||||
This password signs you into Vantage HQ and into every instance you are given
|
||||
access to. Nobody who invited you can see it.
|
||||
</p>
|
||||
<Field
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Button type="submit" disabled={accept.isPending || password.length < 12}>
|
||||
{accept.isPending ? "Setting…" : "Set password"}
|
||||
</Button>
|
||||
</form>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
accept.mutate();
|
||||
}}
|
||||
>
|
||||
<p className="text-[0.86rem] text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to.</p>
|
||||
<Field
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<Button type="submit" disabled={accept.isPending || password.length < 12} className="w-full justify-center">
|
||||
{accept.isPending ? "Setting…" : "Set password and continue"}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-16">
|
||||
<Suspense fallback={<p className="text-ink-3">Loading…</p>}>
|
||||
<AcceptForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Suspense fallback={<AuthShell title="Choose a password" lede="One moment." />}>
|
||||
<AcceptForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
||||
|
||||
/*
|
||||
* The masthead deliberately does NOT live here. It belongs to the authenticated
|
||||
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
|
||||
* layouts, so /login, /verify and /accept-invite stop rendering a bar
|
||||
* whose navigation and account menu they cannot use.
|
||||
*/
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
import { AuthShell } from "@/components/AuthShell";
|
||||
|
||||
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -37,23 +39,25 @@ export default function LoginPage() {
|
||||
|
||||
if (offline)
|
||||
return (
|
||||
<Main>
|
||||
<AuthShell title="Sign in">
|
||||
<NotConnectedPanel url={API_BASE} />
|
||||
</Main>
|
||||
</AuthShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<h1 className="text-3xl">Sign in</h1>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<AuthShell
|
||||
title="Sign in"
|
||||
lede="Licences, instances and billing for your account."
|
||||
/*
|
||||
* HQ and the Vantage console are separate sign-ins on separate
|
||||
* hosts, and the two get confused — someone lands here with their
|
||||
* console password and reads the generic failure as a broken
|
||||
* account. Saying which door this is costs one line.
|
||||
*/
|
||||
footnote="This is the portal for your licence and billing. Your servers are managed inside your Vantage instance, which signs in separately."
|
||||
>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<Field label="Email" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
@@ -64,26 +68,27 @@ export default function LoginPage() {
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[0.82rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={staff}
|
||||
onChange={(e) => setStaff(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={staff} onChange={(e) => setStaff(e.target.checked)} className="accent-[var(--accent)]" />
|
||||
I work at Vantage
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
<Link href="/signup" className="text-[0.82rem] text-accent underline">
|
||||
Create an account for a self-hosted licence
|
||||
</Link>
|
||||
</div>
|
||||
<Button type="submit" disabled={busy} className="w-full justify-center">
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</Main>
|
||||
|
||||
{SITE_URL && (
|
||||
<>
|
||||
<div className="h-px bg-rule-soft" />
|
||||
|
||||
{/* Signup lives on the marketing site's /start, not here. */}
|
||||
<p className="text-center text-[0.82rem] text-ink-3">
|
||||
No account?{" "}
|
||||
<a href={`${SITE_URL}/start`} className="text-accent underline">
|
||||
Create one
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Main({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto max-w-rail px-5 py-12">{children}</main>;
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApiError, NotConnected, api } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [form, setForm] = useState({ name: "", email: "", password: "", website: "" });
|
||||
const [state, setState] = useState<"idle" | "busy" | "sent">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setState("busy");
|
||||
setError(null);
|
||||
try {
|
||||
await api.signup(form);
|
||||
setState("sent");
|
||||
} catch (err) {
|
||||
setState("idle");
|
||||
setError(
|
||||
err instanceof NotConnected
|
||||
? "The licensing service is not reachable from this page."
|
||||
: err instanceof ApiError
|
||||
? err.message
|
||||
: "Could not create the account. Try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
{state === "sent" ? (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">Check your email</h1>
|
||||
<p className="text-ink-2">
|
||||
We sent a link to {form.email}. Open it to finish setting up your account —
|
||||
it expires in 24 hours. Nothing is created until you do.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-3xl">Create an account</h1>
|
||||
<p className="mt-2 max-w-xl text-ink-2">
|
||||
For self-hosted licences. If you run on our cloud, sign in with the same
|
||||
details you use for your Vantage instance.
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-6 grid gap-4">
|
||||
<Field
|
||||
label="Organisation"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
required
|
||||
minLength={12}
|
||||
hint="At least 12 characters."
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
{/* Honeypot: off-screen, unlabelled for humans, irresistible to bots. */}
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
value={form.website}
|
||||
onChange={(e) => setForm({ ...form, website: e.target.value })}
|
||||
className="absolute left-[-9999px] h-0 w-0"
|
||||
/>
|
||||
<Button type="submit" disabled={state === "busy"}>
|
||||
{state === "busy" ? "Creating…" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { AuthMessage, AuthShell } from "@/components/AuthShell";
|
||||
|
||||
const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
function Verify() {
|
||||
const router = useRouter();
|
||||
@@ -25,50 +27,59 @@ function Verify() {
|
||||
router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
}, [needsPassword, token, router]);
|
||||
if (needsPassword) return <Message title="One moment…" body="Taking you to set a password." />;
|
||||
if (needsPassword) return <AuthShell title="One moment…" lede="Taking you to set a password." />;
|
||||
|
||||
if (!token)
|
||||
return (
|
||||
<Message
|
||||
<AuthMessage
|
||||
title="That link is incomplete"
|
||||
body="It is missing its token. Use the link in the email exactly as sent."
|
||||
body="It is missing its token. Use the link in the email exactly as sent — some mail clients cut long links in half."
|
||||
action={{ href: "/login", label: "Go to sign in" }}
|
||||
/>
|
||||
);
|
||||
if (isLoading) return <Message title="Verifying…" body="One moment." />;
|
||||
|
||||
if (isLoading) return <AuthShell title="Verifying…" lede="One moment." />;
|
||||
|
||||
if (error || !data?.verified)
|
||||
return (
|
||||
<Message
|
||||
<AuthMessage
|
||||
title="That link is invalid or has expired"
|
||||
body="Links last 24 hours and can only be used once. Sign up again to get a fresh one."
|
||||
body="Links last 24 hours and can only be used once. Signing in will send you a fresh one."
|
||||
action={{ href: "/login", label: "Go to sign in" }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">Email verified</h1>
|
||||
<p className="text-ink-2">Your account is ready.</p>
|
||||
<Link href="/login" className="justify-self-start text-accent underline">
|
||||
<AuthShell
|
||||
title="Email verified"
|
||||
lede="Your account is ready."
|
||||
footnote={
|
||||
SITE_URL ? (
|
||||
<>
|
||||
New to Vantage? The{" "}
|
||||
<a href={`${SITE_URL}/docs`} className="text-accent underline">
|
||||
getting started guide
|
||||
</a>{" "}
|
||||
walks through your first instance.
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<p className="text-[0.9rem] text-ink-2">Sign in to create your first instance. The Free tier covers 5 servers and needs no card.</p>
|
||||
<a
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
<h1 className="text-3xl">{title}</h1>
|
||||
<p className="text-ink-2">{body}</p>
|
||||
</div>
|
||||
</a>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-rail px-5 py-12">
|
||||
<Suspense fallback={null}>
|
||||
<Verify />
|
||||
</Suspense>
|
||||
</main>
|
||||
<Suspense fallback={<AuthShell title="Verifying…" lede="One moment." />}>
|
||||
<Verify />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type NavLink = { href: string; label: string };
|
||||
* which environment you are in.
|
||||
*
|
||||
* It replaces a brand bar and a separate nav strip. The nav's active state is
|
||||
* derived from the pathname rather than hardcoded — the previous customer nav
|
||||
* derived from the pathname rather than hardcoded the previous customer nav
|
||||
* marked Overview as current on every page, including the ones that weren't it.
|
||||
*
|
||||
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
|
||||
@@ -21,24 +21,14 @@ export type NavLink = { href: string; label: string };
|
||||
* that distinction from each other too, and one shade plus one chip buys it
|
||||
* without a second palette.
|
||||
*/
|
||||
export function AppBar({
|
||||
links,
|
||||
context,
|
||||
staff = false,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
context?: React.ReactNode;
|
||||
staff?: boolean;
|
||||
}) {
|
||||
export function AppBar({ links, context, staff = false }: { links: NavLink[]; context?: React.ReactNode; staff?: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isCurrent = (href: string) =>
|
||||
// The section root matches only exactly; deeper routes match by prefix,
|
||||
// so /staff/accounts/:id still lights Accounts while /staff/accounts
|
||||
// does not light Operations.
|
||||
href === "/" || href === "/staff"
|
||||
? pathname === href
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
href === "/" || href === "/staff" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
|
||||
@@ -46,9 +36,7 @@ export function AppBar({
|
||||
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
|
||||
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
|
||||
</span>
|
||||
{context && (
|
||||
<>
|
||||
@@ -70,9 +58,7 @@ export function AppBar({
|
||||
href={l.href}
|
||||
aria-current={on ? "page" : undefined}
|
||||
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
|
||||
on
|
||||
? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']"
|
||||
: "text-ink-3 hover:text-ink-2"
|
||||
on ? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']" : "text-ink-3 hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/*
|
||||
* The frame for every screen you can reach without a session: sign in, email
|
||||
* verification, and accepting an invitation.
|
||||
*
|
||||
* These three had drifted into three different layouts. Sign in was a centred
|
||||
* 26rem card with the lockup above it; verify and accept-invite were bare
|
||||
* left-aligned text on the full 1200px rail, with no masthead, no panel and no
|
||||
* brand anywhere on the page. Those two are the first screens a new customer
|
||||
* ever sees — arriving from an email, on a domain they have not visited before
|
||||
* — and they were the two that did not say whose product this is.
|
||||
*
|
||||
* There is no AppBar here on purpose: it carries navigation and an account
|
||||
* menu, and none of it works without a session.
|
||||
*/
|
||||
export function AuthShell({
|
||||
title,
|
||||
lede,
|
||||
children,
|
||||
footnote,
|
||||
}: {
|
||||
title: string;
|
||||
lede?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/** Sits outside the panel: orientation, not part of the task. */
|
||||
footnote?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-[26rem] flex-col justify-center px-5 py-12">
|
||||
{/* The masthead's lockup, unlinked: there is nowhere to go yet. */}
|
||||
<div className="mb-7 flex flex-col items-center gap-2 text-center">
|
||||
<span className="flex items-baseline gap-2 text-[1.5rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.78rem] font-normal uppercase tracking-[0.14em] text-ink-3">HQ</span>
|
||||
</span>
|
||||
<h1 className="text-[1.16rem]">{title}</h1>
|
||||
{lede && <p className="text-[0.86rem] text-ink-2">{lede}</p>}
|
||||
</div>
|
||||
|
||||
{children && <div className="grid gap-4 rounded border border-rule bg-panel p-6 shadow-[var(--shadow)]">{children}</div>}
|
||||
|
||||
{footnote && <div className="mt-5 text-center text-[0.8rem] text-ink-3">{footnote}</div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* A terminal state — verified, expired, already used, invalid. Always says what
|
||||
* happened and what to do next: a dead end that only reports the failure leaves
|
||||
* someone holding an email they cannot act on.
|
||||
*/
|
||||
export function AuthMessage({ title, body, action }: { title: string; body: React.ReactNode; action?: { href: string; label: string } }) {
|
||||
return (
|
||||
<AuthShell title={title}>
|
||||
<p className="text-[0.9rem] text-ink-2">{body}</p>
|
||||
{action && (
|
||||
<Link
|
||||
href={action.href}
|
||||
className="inline-flex items-center justify-center gap-2 rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink no-underline"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -8,13 +8,36 @@ type Variant = "solid" | "line";
|
||||
* border on the secondary variant. site/ does not have an accent-outlined
|
||||
* button and this app should not invent one.
|
||||
*/
|
||||
/*
|
||||
* The height every form control resolves to, buttons included.
|
||||
*
|
||||
* Padding alone cannot align them: a select is mono at 0.84rem and a button is
|
||||
* sans at 0.94rem, so identical padding still leaves them ~7px apart and a
|
||||
* filter row looks assembled from two different kits. It is the height the
|
||||
* button's own padding already computed to, so buttons do not move — everything
|
||||
* else comes up to meet them.
|
||||
*/
|
||||
export const CONTROL_HEIGHT = "h-11";
|
||||
|
||||
/*
|
||||
* An input or select that sits on a form row with a button. Mono, because in
|
||||
* this product the values typed into these are addresses, UUIDs and price IDs.
|
||||
*/
|
||||
export function controlClass(className?: string) {
|
||||
return clsx(
|
||||
CONTROL_HEIGHT,
|
||||
"w-full rounded border border-rule bg-panel-2 px-2.5 font-mono text-[0.88rem] text-ink",
|
||||
"focus:border-accent focus:outline-none",
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
|
||||
return clsx(
|
||||
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
|
||||
"inline-flex items-center gap-2 rounded border px-4 text-[0.94rem] font-semibold",
|
||||
CONTROL_HEIGHT,
|
||||
"transition-[filter,border-color] duration-150 hover:brightness-110",
|
||||
variant === "solid"
|
||||
? "border-accent bg-accent text-accent-ink"
|
||||
: "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
variant === "solid" ? "border-accent bg-accent text-accent-ink" : "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
|
||||
className,
|
||||
);
|
||||
@@ -28,22 +51,10 @@ export function Button({ variant = "solid", className, ...rest }: Props) {
|
||||
|
||||
/*
|
||||
* A link that looks like a button. It exists so a navigation action never has to
|
||||
* be an <a> wrapped around a <button> — invalid markup, and it gives screen
|
||||
* be an <a> wrapped around a <button> invalid markup, and it gives screen
|
||||
* readers two nested controls where the page means one.
|
||||
*/
|
||||
export function LinkButton({
|
||||
href,
|
||||
variant = "solid",
|
||||
external,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
variant?: Variant;
|
||||
external?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function LinkButton({ href, variant = "solid", external, className, children }: { href: string; variant?: Variant; external?: boolean; className?: string; children: React.ReactNode }) {
|
||||
const cls = buttonClass(variant, false, className);
|
||||
return external ? (
|
||||
<a href={href} className={cls}>
|
||||
|
||||
@@ -5,30 +5,12 @@ import { Button } from "./Button";
|
||||
/*
|
||||
* Editing a plan changes what every future customer gets, so the confirmation
|
||||
* names each field rather than asking "are you sure". Existing licences
|
||||
* snapshotted their plan at issue time and are genuinely unaffected — saying so
|
||||
* snapshotted their plan at issue time and are genuinely unaffected saying so
|
||||
* is what stops a well-meaning edit being followed by a panicked reissue.
|
||||
*/
|
||||
export function ConfirmPlanChange({
|
||||
plan,
|
||||
next,
|
||||
issuedCount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
plan: Plan;
|
||||
next: Plan;
|
||||
issuedCount: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
export function ConfirmPlanChange({ plan, next, issuedCount, onConfirm, onCancel }: { plan: Plan; next: Plan; issuedCount: number; onConfirm: () => void; onCancel: () => void }) {
|
||||
const rows: { field: string; was: string; now: string }[] = [];
|
||||
const fields = [
|
||||
"max_servers",
|
||||
"max_monitors",
|
||||
"max_secret_groups",
|
||||
"max_channels",
|
||||
"audit_retention_days",
|
||||
] as const;
|
||||
const fields = ["max_servers", "max_monitors", "max_secret_groups", "max_channels", "audit_retention_days"] as const;
|
||||
for (const f of fields) {
|
||||
if (plan.base_limits[f] !== next.base_limits[f])
|
||||
rows.push({
|
||||
@@ -63,10 +45,7 @@ export function ConfirmPlanChange({
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing would change.</li>}
|
||||
</ul>
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
This applies to licences issued from now on. The {issuedCount} licences already
|
||||
issued keep what they were signed with until each is reissued.
|
||||
</p>
|
||||
<p className="text-[0.82rem] text-ink-3">This applies to licences issued from now on. The {issuedCount} licences already issued keep what they were signed with until each is reissued.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Change plan
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { controlClass } from "./Button";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
className,
|
||||
...input
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
@@ -10,18 +13,15 @@ export function Field({
|
||||
}) {
|
||||
return (
|
||||
<label className="grid max-w-md gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
{...input}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
/>
|
||||
{error ? (
|
||||
<span className="text-[0.82rem] text-expired">{error}</span>
|
||||
) : hint ? (
|
||||
<span className="text-[0.82rem] text-ink-3">{hint}</span>
|
||||
) : null}
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">{label}</span>
|
||||
{/*
|
||||
* className is pulled out of the spread rather than left in it: it
|
||||
* used to be spread onto the input and then overwritten by the
|
||||
* hardcoded one below, so a caller passing className got nothing and
|
||||
* no warning.
|
||||
*/}
|
||||
<input {...input} className={controlClass(className)} aria-invalid={error ? true : undefined} />
|
||||
{error ? <span className="text-[0.82rem] text-expired">{error}</span> : hint ? <span className="text-[0.82rem] text-ink-3">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||
import { api, type Instance, type License } from "@/lib/api";
|
||||
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { StatePill } from "./StatePill";
|
||||
import { TermBar } from "./TermBar";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
|
||||
const STRIPE = {
|
||||
@@ -21,7 +22,7 @@ const KEY = (id: string) => `vantage-hq-record-open:${id}`;
|
||||
/*
|
||||
* One instance, open or closed.
|
||||
*
|
||||
* Closed it is a row — name, tier, host, term bar, state. Open it adds what the
|
||||
* Closed it is a row name, tier, host, term bar, state. Open it adds what the
|
||||
* licence includes, who can sign in, and the actions. Deliberately ONE component
|
||||
* rather than a card and a detail panel: two components meant a single-instance
|
||||
* account got a third of a row of summary with its substance a click away, and
|
||||
@@ -35,7 +36,6 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
|
||||
const state = licenceState(license?.expires_at, Boolean(license));
|
||||
const days = license ? daysRemaining(license.expires_at) : 0;
|
||||
const cloud = instance.deployment === "cloud";
|
||||
const termDays = instance.tier === "free" ? 30 : 365;
|
||||
const deleteInDays = license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -102,22 +102,10 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{license && state !== "expired" && (
|
||||
<div className="grid max-w-md gap-1.5">
|
||||
<div className="flex justify-between font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
<span>{days} days remaining</span>
|
||||
<span>Renews {formatDate(license.expires_at)}</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-sm bg-rule-soft">
|
||||
<div
|
||||
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
|
||||
style={{
|
||||
width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* The term is drawn for an expired licence too. The old bar hid
|
||||
itself once it lapsed, which removed the measurement at exactly
|
||||
the moment it started mattering. */}
|
||||
{license && <TermBar issuedAt={license.issued_at} expiresAt={license.expires_at} state={state} className="max-w-md" />}
|
||||
|
||||
{state === "expired" && (
|
||||
<div className="grid gap-1">
|
||||
@@ -168,14 +156,10 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{state === "none" ? (
|
||||
// A paid placeholder (awaiting_link) claims its real install
|
||||
// UUID in place. Anything else without a licence gets one from
|
||||
// the purchase page (self-hosted Free is created there).
|
||||
instance.status === "awaiting_link" ? (
|
||||
<LinkButton href={`/instances/link?claim=${instance.instance_id}`}>Link an install</LinkButton>
|
||||
) : (
|
||||
<LinkButton href="/purchase">Get a licence</LinkButton>
|
||||
)
|
||||
// Every unlicensed instance is answered from the purchase
|
||||
// page — self-hosted Free and paid both start there, and
|
||||
// both name the install's own UUID.
|
||||
<LinkButton href="/purchase">Get a licence</LinkButton>
|
||||
) : cloud && instance.slug ? (
|
||||
<>
|
||||
<LinkButton external href={`https://${instance.slug}.vantage.hostxtra.co.uk`}>
|
||||
|
||||
@@ -13,16 +13,12 @@ const REASON: Record<License["reason"], string> = {
|
||||
/*
|
||||
* Licences are append-only: a renewal supersedes its predecessor rather than
|
||||
* replacing it. So this is a ledger, not a table. Superseded rows stay visible
|
||||
* and are overprinted the way a cancelled instrument is — hiding them would
|
||||
* and are overprinted the way a cancelled instrument is hiding them would
|
||||
* destroy the only record of why an instance stopped working on a given date.
|
||||
*/
|
||||
export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
if (licenses.length === 0) {
|
||||
return (
|
||||
<p className="text-ink-2">
|
||||
No licence has ever been issued for this instance, so it is read-only.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-ink-2">No licence has ever been issued for this instance, so it is read-only.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -30,22 +26,9 @@ export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
{licenses.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
<li
|
||||
key={l.license_id}
|
||||
className={clsx(
|
||||
"grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]",
|
||||
dead && "text-ink-3",
|
||||
)}
|
||||
>
|
||||
<li key={l.license_id} className={clsx("grid gap-4 border-b border-rule-soft py-4 last:border-0 sm:grid-cols-[9.5rem_1fr]", dead && "text-ink-3")}>
|
||||
<div className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
<b
|
||||
className={clsx(
|
||||
"block text-[0.82rem] font-semibold",
|
||||
dead ? "text-ink-3" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{formatDate(l.issued_at)}
|
||||
</b>
|
||||
<b className={clsx("block text-[0.82rem] font-semibold", dead ? "text-ink-3" : "text-ink")}>{formatDate(l.issued_at)}</b>
|
||||
{formatStamp(l.issued_at)}
|
||||
</div>
|
||||
<div className="grid justify-items-start gap-1.5">
|
||||
@@ -56,20 +39,14 @@ export function Ledger({ licenses }: { licenses: License[] }) {
|
||||
)}
|
||||
<p className="flex flex-wrap items-center gap-2 font-semibold">
|
||||
{l.tier.replace("_", " ")}
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">
|
||||
{REASON[l.reason]}
|
||||
</span>
|
||||
<span className="rounded-sm border border-rule px-1.5 py-0.5 font-mono text-[0.72rem] font-normal uppercase tracking-[0.09em] text-accent">{REASON[l.reason]}</span>
|
||||
</p>
|
||||
<p className="font-mono text-[0.72rem] tabular-nums text-ink-3">
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} ·{" "}
|
||||
{limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.license_id.slice(0, 8)} · expires {formatDate(l.expires_at)} · {limitLabel(l.limits.max_servers)} servers · issued by {l.issued_by}
|
||||
{l.superseded_by && (
|
||||
<>
|
||||
{" "}
|
||||
· replaced by{" "}
|
||||
<span className="text-accent underline">
|
||||
{l.superseded_by.slice(0, 8)}
|
||||
</span>
|
||||
· replaced by <span className="text-accent underline">{l.superseded_by.slice(0, 8)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -1,73 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Panel } from "./Panel";
|
||||
|
||||
/*
|
||||
* A licence blob is signed public data, not a secret — it is useless on any
|
||||
* instance other than the one it names. So it is safe to show inline, and
|
||||
* showing it is what stops a blocked download from blocking a paying customer.
|
||||
* That is also why it is never collapsed behind a toggle: someone whose
|
||||
* clipboard and download are both blocked has to be able to select it by hand.
|
||||
*
|
||||
* It is evidence rather than content, so it is set in a well with a keyed strip
|
||||
* saying what it is and how much of it there is, and given a fixed height. It
|
||||
* used to run to 250px of base64 and was the largest thing on the page, which
|
||||
* is a strange amount of room to give a string nobody reads.
|
||||
*
|
||||
* The download lives in the page header beside Renew, not here — it was in both
|
||||
* places, which is one button too many for one file.
|
||||
*/
|
||||
export function LicenceDelivery({
|
||||
instanceId,
|
||||
blob,
|
||||
downloadUrl,
|
||||
}: {
|
||||
instanceId: string;
|
||||
blob: string;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
export function LicenceDelivery({ blob }: { instanceId: string; blob: string; downloadUrl: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(blob);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
try {
|
||||
await navigator.clipboard.writeText(blob);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard is refused without a secure context or a gesture the
|
||||
// browser trusts. The blob is on screen and selectable either way,
|
||||
// so this needs no error state.
|
||||
}
|
||||
}
|
||||
|
||||
const steps = [
|
||||
<>
|
||||
Open <code className="rounded-sm bg-accent-wash px-1">Settings → Licence</code> on your
|
||||
install.
|
||||
Open <Code>Settings → Licence</Code> on your install.
|
||||
</>,
|
||||
<>Paste the licence into the box and save.</>,
|
||||
<>
|
||||
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight
|
||||
away — no restart.
|
||||
The page reports <Code>Valid</Code> straight away — no restart.
|
||||
</>,
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
<h2 className="text-xl">Your licence</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={`vantage-${instanceId}.lic`}
|
||||
className="inline-flex items-center gap-2 rounded border border-accent bg-accent px-4 py-2.5 text-[0.94rem] font-semibold text-accent-ink"
|
||||
>
|
||||
Download licence
|
||||
</a>
|
||||
<Button variant="line" type="button" onClick={copy}>
|
||||
{copied ? "Copied" : "Copy to clipboard"}
|
||||
</Button>
|
||||
<Panel title="Your licence" meta="Paste into your install">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Licence key</span>
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">{blob.length.toLocaleString()} characters</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Dashed, because this is data to be carried somewhere else
|
||||
rather than a surface to read. */}
|
||||
<pre className="max-h-32 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 pr-24 font-mono text-[0.7rem] leading-relaxed text-ink-2">
|
||||
{blob}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="absolute right-2 top-2 rounded border border-rule bg-panel px-2.5 py-1 font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-2 hover:border-accent hover:text-accent"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-all rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
|
||||
{blob}
|
||||
</pre>
|
||||
|
||||
{/* Numbered because this is an actual sequence — each step is only
|
||||
possible once the one before it is done. */}
|
||||
<ol className="grid gap-2">
|
||||
{steps.map((body, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{body}</span>
|
||||
<li key={i} className="grid grid-cols-[1.5rem_1fr] items-start gap-3 text-[0.84rem] text-ink-2">
|
||||
<span className="grid h-[1.4rem] place-items-center rounded-sm border border-rule font-mono text-[0.68rem] text-accent">{i + 1}</span>
|
||||
<span className="leading-[1.4rem]">{body}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children }: { children: React.ReactNode }) {
|
||||
return <code className="rounded-sm bg-accent-wash px-1 font-mono text-[0.8rem] text-ink">{children}</code>;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,43 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { ApiError, api, type InstanceRole } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Button, controlClass } from "@/components/Button";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
|
||||
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
|
||||
|
||||
/*
|
||||
* What each rank actually lets someone do, in the instance rather than in the
|
||||
* portal. The select used to offer three words with no statement of what they
|
||||
* bought — which is a permissions control that declines to explain permissions.
|
||||
*/
|
||||
const ROLE_GRANTS: Record<InstanceRole, string> = {
|
||||
owner: "Everything, including billing and deleting the instance.",
|
||||
admin: "Manage servers, workflows, secrets and settings.",
|
||||
member: "Use the instance. Cannot change settings or members.",
|
||||
};
|
||||
|
||||
const SELECT_QUIET =
|
||||
"rounded border border-transparent bg-transparent px-2 py-1 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-2 hover:border-rule focus:border-accent focus:text-ink focus:outline-none";
|
||||
|
||||
/* Same height as the Grant access button beside it — see controlClass. */
|
||||
const SELECT = controlClass("bg-panel");
|
||||
|
||||
/*
|
||||
* The access roster for one instance.
|
||||
*
|
||||
* Absent entirely for self-hosted instances — the backend refuses those, and a
|
||||
* panel that renders controls the server will reject is a panel that lies.
|
||||
*
|
||||
* The row is a monogram and an address set in mono, because in this product an
|
||||
* identity IS an address, and every other identifier on the screen — the
|
||||
* instance UUID, the licence reference — is mono too. The role is a fact most
|
||||
* of the time and a control occasionally, so it is drawn as text and only grows
|
||||
* a border on hover or focus: the old row made the dropdown the loudest thing
|
||||
* in it, which is backwards for a list people mostly read.
|
||||
*
|
||||
* Granting sits in its own strip on --panel-2 rather than as a fourth row of
|
||||
* naked controls, so the roster reads as the record and the strip as the action.
|
||||
*/
|
||||
export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const qc = useQueryClient();
|
||||
@@ -18,6 +48,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const [selected, setSelected] = useState("");
|
||||
const [role, setRole] = useState<InstanceRole>("member");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ["members", instanceId],
|
||||
@@ -26,8 +57,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
|
||||
const fail = (e: unknown) =>
|
||||
setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
|
||||
|
||||
const grant = useMutation({
|
||||
mutationFn: () => api.grantMember(instanceId, selected, role),
|
||||
@@ -39,140 +69,181 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
|
||||
onError: fail,
|
||||
});
|
||||
const changeRole = useMutation({
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) =>
|
||||
api.setMemberRole(instanceId, v.uid, v.role),
|
||||
mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
|
||||
onSuccess: refresh,
|
||||
onError: fail,
|
||||
onSuccess: () => {
|
||||
setConfirming(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (e) => {
|
||||
setConfirming(null);
|
||||
fail(e);
|
||||
},
|
||||
});
|
||||
|
||||
const myRole = session?.account_role;
|
||||
const canManage = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
|
||||
const candidates = (people.data ?? []).filter(
|
||||
(p) => !granted.has(p.user_id) && p.verified_at,
|
||||
);
|
||||
const rows = members.data ?? [];
|
||||
const granted = new Set(rows.map((m) => m.customer_user_id));
|
||||
const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
|
||||
const pending = (people.data ?? []).filter((p) => !p.verified_at).length;
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 rounded border border-rule bg-panel p-5">
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-xl">Who can sign in</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Each person here has a real user inside this instance and signs in with their
|
||||
Vantage HQ password.
|
||||
</p>
|
||||
<Panel title="Who can sign in" meta={rows.length ? `${rows.length} ${rows.length === 1 ? "person" : "people"}` : undefined} bodyless>
|
||||
<div className="grid gap-3 px-4 pb-4 pt-3.5">
|
||||
<p className="text-[0.84rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
|
||||
{error && (
|
||||
<p role="alert" className="rounded border border-rule border-l-[3px] border-l-expired bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Nobody else can sign in yet."
|
||||
body={canManage ? "Add someone from your account below and a user is created for them inside this instance." : "An owner or admin can grant access."}
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid border-t border-rule-soft">
|
||||
{/*
|
||||
* Two columns on a phone — monogram and address — with the
|
||||
* controls dropping to their own full-width row beneath;
|
||||
* three columns from sm up, controls right-aligned. As one
|
||||
* wrapping flex row the address competed with a select and
|
||||
* two buttons for 320px and lost, and the confirm step put
|
||||
* three more elements into the same row.
|
||||
*/}
|
||||
{rows.map((m) => (
|
||||
<li
|
||||
key={m.member_id}
|
||||
className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 border-b border-rule-soft px-4 py-3 last:border-b-0 sm:grid-cols-[auto_1fr_auto]"
|
||||
>
|
||||
<span aria-hidden className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.62rem] font-bold text-accent-ink">
|
||||
{m.email.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 break-all font-mono text-[0.84rem] sm:truncate sm:break-normal">{m.email}</span>
|
||||
|
||||
<ul className="grid gap-2">
|
||||
{(members.data ?? []).map((m) => (
|
||||
<li
|
||||
key={m.member_id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2"
|
||||
>
|
||||
<span>{m.email}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{canManage ? (
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={(e) =>
|
||||
changeRole.mutate({
|
||||
uid: m.customer_user_id,
|
||||
role: e.target.value as InstanceRole,
|
||||
})
|
||||
}
|
||||
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-mono text-[0.82rem]">{m.role}</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${m.email} from this instance?`))
|
||||
revoke.mutate(m.customer_user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{members.data?.length === 0 && (
|
||||
<li className="text-ink-2">Nobody has been added yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-2 sm:col-span-1 sm:flex-nowrap sm:justify-end">
|
||||
{canManage ? (
|
||||
<label className="shrink-0">
|
||||
<span className="sr-only">Role for {m.email}</span>
|
||||
<select
|
||||
value={m.role}
|
||||
title={ROLE_GRANTS[m.role]}
|
||||
onChange={(e) => changeRole.mutate({ uid: m.customer_user_id, role: e.target.value as InstanceRole })}
|
||||
className={SELECT_QUIET}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className="shrink-0 font-mono text-[0.78rem] uppercase tracking-[0.08em] text-ink-3">{m.role}</span>
|
||||
)}
|
||||
|
||||
{canManage &&
|
||||
/*
|
||||
* Confirming inline rather than through
|
||||
* window.confirm(), and in the row itself rather
|
||||
* than a dialog: it can say what revoking does,
|
||||
* where the eye already is.
|
||||
*/
|
||||
(confirming === m.customer_user_id ? (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<span className="text-[0.8rem] text-ink-2">Revoke access?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-expired px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-expired hover:bg-expired hover:text-panel disabled:opacity-50"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => revoke.mutate(m.customer_user_id)}
|
||||
>
|
||||
{revoke.isPending ? "Revoking…" : "Revoke"}
|
||||
</button>
|
||||
<button type="button" className="font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:text-ink" onClick={() => setConfirming(null)}>
|
||||
Keep
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
/* Quiet until intent: a row that is mostly read
|
||||
should not carry a permanently red control. */
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded border border-transparent px-2 py-0.5 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-ink-3 hover:border-expired hover:text-expired"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setConfirming(m.customer_user_id);
|
||||
}}
|
||||
>
|
||||
Revoke<span className="sr-only"> access for {m.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (selected) grant.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Add someone
|
||||
</span>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
<option value="">Choose a person…</option>
|
||||
{candidates.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
{p.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Role here
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as InstanceRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" disabled={!selected || grant.isPending}>
|
||||
{grant.isPending ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<div className="grid gap-3 border-t border-rule bg-panel-2 px-4 py-3.5">
|
||||
{/* Stacked and full width on a phone; one row from sm up.
|
||||
Three controls side by side left the person select about
|
||||
90px wide, which is not enough to read an address in. */}
|
||||
<form
|
||||
className="grid gap-3 sm:flex sm:flex-wrap sm:items-end"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (selected) grant.mutate();
|
||||
}}
|
||||
>
|
||||
<label className="grid min-w-0 gap-1.5 sm:flex-1">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Grant access to</span>
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)} className={SELECT} disabled={candidates.length === 0}>
|
||||
<option value="">{candidates.length === 0 ? "Everyone already has access" : "Choose a person…"}</option>
|
||||
{candidates.map((p) => (
|
||||
<option key={p.user_id} value={p.user_id}>
|
||||
{p.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">As</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className={SELECT}>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" disabled={!selected || grant.isPending} className="w-full justify-center sm:w-auto">
|
||||
{grant.isPending ? "Granting…" : "Grant access"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{canManage && pending > 0 && (
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted
|
||||
yet and cannot be added until they do.
|
||||
</p>
|
||||
{/* The chosen rank explains itself, rather than leaving three
|
||||
words to be guessed at. */}
|
||||
<p className="text-[0.8rem] text-ink-3">
|
||||
<span className="font-mono uppercase tracking-[0.08em]">{role}</span> — {ROLE_GRANTS[role]}
|
||||
</p>
|
||||
|
||||
{pending > 0 && (
|
||||
<p className="text-[0.8rem] text-ink-3">
|
||||
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet, and cannot be granted access until they do.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* The deployment failure this repo makes most often, made legible. It names the
|
||||
* variable, the value baked in, and both reasons it fails — unreachable from
|
||||
* variable, the value baked in, and both reasons it fails unreachable from
|
||||
* the browser, or missing from admin's ADMIN_ORIGIN.
|
||||
*/
|
||||
export function NotConnectedPanel({ url }: { url: string }) {
|
||||
@@ -9,19 +9,16 @@ export function NotConnectedPanel({ url }: { url: string }) {
|
||||
<h2 className="text-xl text-expired">Not connected to the licensing service</h2>
|
||||
{url ? (
|
||||
<p className="text-ink-2">
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> ={" "}
|
||||
<code className="text-ink">{url}</code>, which did not respond.
|
||||
This build points at <code className="text-ink">ADMIN_API_URL</code> = <code className="text-ink">{url}</code>, which did not respond.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-ink-2">
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was
|
||||
built, so there is nowhere to send requests.
|
||||
<code className="text-ink">ADMIN_API_URL</code> was not set when this app was built, so there is nowhere to send requests.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[0.82rem] text-ink-3">
|
||||
The value is baked in when the image is built and has to be reachable from your
|
||||
browser, not just from the server. It also has to appear in the licensing
|
||||
service’s <code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
The value is baked in when the image is built and has to be reachable from your browser, not just from the server. It also has to appear in the licensing service’s{" "}
|
||||
<code>ADMIN_ORIGIN</code>, or the browser blocks every request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,16 +7,10 @@
|
||||
* many instances exist, so the page has a floor.
|
||||
*
|
||||
* It collapses below lg in source order, which puts the main column first on a
|
||||
* phone. Nothing is hidden at any width — if content only fits on a desktop it
|
||||
* phone. Nothing is hidden at any width if content only fits on a desktop it
|
||||
* does not belong in the rail.
|
||||
*/
|
||||
export function PageFrame({
|
||||
children,
|
||||
aside,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
aside?: React.ReactNode;
|
||||
}) {
|
||||
export function PageFrame({ children, aside }: { children: React.ReactNode; aside?: React.ReactNode }) {
|
||||
if (!aside) return <div className="grid gap-5">{children}</div>;
|
||||
|
||||
return (
|
||||
@@ -28,24 +22,12 @@ export function PageFrame({
|
||||
}
|
||||
|
||||
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
|
||||
export function RailCard({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number | string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function RailCard({ title, count, children }: { title: string; count?: number | string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
|
||||
<header className="flex items-baseline justify-between gap-2.5">
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
{count !== undefined && (
|
||||
<b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>
|
||||
)}
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">{title}</h2>
|
||||
{count !== undefined && <b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
@@ -59,9 +41,7 @@ export function RailFacts({ rows }: { rows: { label: string; value: React.ReactN
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
|
||||
<dt className="text-ink-3">{r.label}</dt>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">
|
||||
{r.value}
|
||||
</dd>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
|
||||
/*
|
||||
* One record-line entry. `copy` marks the value as worth lifting to the
|
||||
* clipboard — an instance UUID or a licence ID, the strings people paste into
|
||||
* clipboard an instance UUID or a licence ID, the strings people paste into
|
||||
* support tickets.
|
||||
*/
|
||||
export type RecordField = { key: string; value: string; copy?: boolean };
|
||||
@@ -15,6 +15,8 @@ function CopyButton({ value }: { value: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
// Never the thing that wraps: it is 5 characters and the value
|
||||
// beside it may be 36.
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
@@ -26,7 +28,7 @@ function CopyButton({ value }: { value: string }) {
|
||||
// selectable either way, so this needs no error state.
|
||||
}
|
||||
}}
|
||||
className="rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
|
||||
className="shrink-0 rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
|
||||
>
|
||||
{done ? "Copied" : "Copy"}
|
||||
</button>
|
||||
@@ -60,10 +62,7 @@ export function PageHeader({
|
||||
return (
|
||||
<header className="grid gap-3">
|
||||
{back && (
|
||||
<Link
|
||||
href={back.href}
|
||||
className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent"
|
||||
>
|
||||
<Link href={back.href} className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent">
|
||||
← {back.label}
|
||||
</Link>
|
||||
)}
|
||||
@@ -79,13 +78,13 @@ export function PageHeader({
|
||||
{(record?.length || status) && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
|
||||
{record?.map((f) => (
|
||||
<span key={f.key} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
{f.key}
|
||||
</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
{f.value}
|
||||
</span>
|
||||
// min-w-0 and break-all because the commonest value here
|
||||
// is a 36-character UUID with a Copy button beside it,
|
||||
// which does not fit a 320px screen as one unbreakable
|
||||
// token and pushed the whole page sideways.
|
||||
<span key={f.key} className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{f.key}</span>
|
||||
<span className="min-w-0 break-all font-mono text-[0.78rem] tabular-nums text-ink-2">{f.value}</span>
|
||||
{f.copy && <CopyButton value={f.value} />}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
/*
|
||||
* The surface every screen is built from.
|
||||
*
|
||||
* Before this there were four panel treatments in the app: `rounded border
|
||||
* border-rule bg-panel p-5` with an `<h2 className="text-xl">`, the same thing
|
||||
* with `text-[0.95rem] font-medium`, a bare `<section className="space-y-2">`
|
||||
* with no border at all, and a table wrapper that was a panel in everything but
|
||||
* name. They were all trying to be the same object.
|
||||
*
|
||||
* The header is title-left, meta-right. Meta is the keyed idiom — mono, small,
|
||||
* tracked, dimmed — because it is always a count, a scope or an identifier,
|
||||
* never prose.
|
||||
*/
|
||||
export function Panel({
|
||||
title,
|
||||
meta,
|
||||
actions,
|
||||
tone,
|
||||
children,
|
||||
bodyless,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
meta?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
/** Draws the panel's own border in a state colour. For a panel that IS the warning. */
|
||||
tone?: "warn" | "expired";
|
||||
children: React.ReactNode;
|
||||
/** Skip the padded body — for a panel whose content is a full-bleed table. */
|
||||
bodyless?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const head = title || meta || actions;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"grid overflow-hidden rounded border bg-panel",
|
||||
tone === "warn" ? "border-warn" : tone === "expired" ? "border-expired" : "border-rule",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{head && (
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
|
||||
{title && <h2 className="text-[0.95rem] font-bold tracking-[-0.01em]">{title}</h2>}
|
||||
<div className="flex items-center gap-3">
|
||||
{meta && <span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">{meta}</span>}
|
||||
{actions}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
{bodyless ? children : <div className="grid gap-3.5 p-4">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* An aside that is part of the argument rather than beside it: the consequence
|
||||
* of the action on screen, or the constraint the reader is about to hit. The
|
||||
* left rule carries the tone, so the note reads as annotation and never as a
|
||||
* second panel competing with the one it sits in.
|
||||
*/
|
||||
export function Note({ tone = "accent", children }: { tone?: "accent" | "warn" | "expired"; children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
className={clsx(
|
||||
"rounded border border-rule border-l-[3px] bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2",
|
||||
tone === "warn" ? "border-l-warn" : tone === "expired" ? "border-l-expired" : "border-l-accent",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* An empty screen is an invitation to act. Every one of these says what the
|
||||
* thing is before offering to make one — "No licences match those filters" on
|
||||
* its own tells someone the filter worked, not what to do about it.
|
||||
*/
|
||||
export function EmptyState({ title, body, action }: { title: string; body?: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid justify-items-center gap-2 px-5 py-12 text-center">
|
||||
<p className="text-[1rem] font-bold">{title}</p>
|
||||
{body && <p className="max-w-[46ch] text-[0.86rem] text-ink-2">{body}</p>}
|
||||
{action && <div className="mt-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
|
||||
import { featureLabel } from "@/lib/features";
|
||||
|
||||
export interface PlanChoice {
|
||||
tier: Tier;
|
||||
@@ -159,7 +160,7 @@ export default function PlanConfigurator({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
|
||||
<span>{featureLabel(key)}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{priced ? "paid add-on" : "included"}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const TONE = {
|
||||
expired: "border-l-expired text-expired",
|
||||
@@ -16,7 +17,11 @@ export function Queue({
|
||||
title: string;
|
||||
count: number;
|
||||
tone: keyof typeof TONE;
|
||||
items: { label: string; href: string; meta: string }[];
|
||||
/* `meta` is a node rather than a string so a queue about time can carry the
|
||||
* term measurement itself. A tier name told the reader what the instance
|
||||
* was; the queue is sorted by how soon it lapses, and that was the one
|
||||
* figure the row did not show. */
|
||||
items: { label: string; href: string; meta: ReactNode }[];
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
@@ -38,12 +43,12 @@ export function Queue({
|
||||
{items.map((i) => (
|
||||
<li
|
||||
key={i.href}
|
||||
className="flex justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
|
||||
className="flex items-center justify-between gap-2 font-mono text-[0.72rem] text-ink-2"
|
||||
>
|
||||
<Link href={i.href} className="text-accent underline">
|
||||
<Link href={i.href} className="truncate text-accent underline">
|
||||
{i.label}
|
||||
</Link>
|
||||
<span className="tabular-nums">{i.meta}</span>
|
||||
<span className="shrink-0 tabular-nums">{i.meta}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -6,38 +6,28 @@ import { Field } from "./Field";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function RelinkPanel({
|
||||
used,
|
||||
max,
|
||||
onRelink,
|
||||
error,
|
||||
}: {
|
||||
instanceId: string;
|
||||
used: number;
|
||||
max: number;
|
||||
onRelink: (newId: string) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
/*
|
||||
* The relink control, and only the control.
|
||||
*
|
||||
* It used to carry its own heading and its own "N of M relinks left this term"
|
||||
* line. It now sits inside the Moves panel, which already says both — a panel
|
||||
* titled Moves with "2 of 3 used" in its header, wrapping a section headed
|
||||
* "Moved to a new server?" that says "1 of 3 relinks left", is the same fact
|
||||
* told twice in two different directions.
|
||||
*
|
||||
* The exhausted case still lives here rather than in the caller: it is the
|
||||
* reason the button is disabled, so it belongs beside the button.
|
||||
*/
|
||||
export function RelinkPanel({ used, max, onRelink, error }: { instanceId: string; used: number; max: number; onRelink: (newId: string) => void; error?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const remaining = Math.max(0, max - used);
|
||||
const exhausted = remaining === 0;
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 border-t border-rule-soft pt-5">
|
||||
<h2 className="text-xl">Moved to a new server?</h2>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Relinking issues a replacement licence for the new install, covering the rest of
|
||||
your current term.
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
{open && !exhausted && (
|
||||
<Field
|
||||
label="New instance ID"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error}
|
||||
hint="From Settings → Licence on the new install."
|
||||
/>
|
||||
<Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
@@ -46,14 +36,14 @@ export function RelinkPanel({
|
||||
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
|
||||
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
|
||||
>
|
||||
Relink to a new install
|
||||
Move to another install
|
||||
</Button>
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
{exhausted
|
||||
? "You have used every relink for this term — contact support and we will sort it out."
|
||||
: `${remaining} of ${max} relinks left this term`}
|
||||
</span>
|
||||
{exhausted ? (
|
||||
<span className="text-[0.82rem] text-ink-3">You have used every move for this term — contact support and we will sort it out.</span>
|
||||
) : (
|
||||
open && <span className="text-[0.82rem] text-ink-3">Relinking issues a replacement licence covering the rest of your current term.</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { Field } from "./Field";
|
||||
import { Note } from "./Panel";
|
||||
import { ApiError, type RenameResult } from "@/lib/api";
|
||||
import { baseSlug, hostFor, slugError } from "@/lib/slug";
|
||||
|
||||
/*
|
||||
* The rename control, and only the control — the same shape as RelinkPanel: an
|
||||
* input that expands in place rather than a modal, because this app has no modal
|
||||
* and one action with one field does not need one.
|
||||
*
|
||||
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
|
||||
* disagree with the server; the 409 that comes back is the answer that counts.
|
||||
*
|
||||
* movesHost is what separates a rename that moves a DNS host from one that only
|
||||
* changes a label. Self-hosted instances and unprovisioned cloud placeholders
|
||||
* have no address, so every word about old links breaking and signing in again
|
||||
* is false for them — and a preview host they will never live at is worse than
|
||||
* no preview at all.
|
||||
*/
|
||||
export function RenamePanel({
|
||||
currentName,
|
||||
currentSlug,
|
||||
movesHost,
|
||||
onRename,
|
||||
}: {
|
||||
currentName: string;
|
||||
currentSlug: string;
|
||||
movesHost: boolean;
|
||||
onRename: (name: string) => Promise<RenameResult>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(currentName);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<RenameResult | undefined>();
|
||||
|
||||
const name = value.trim();
|
||||
const derived = baseSlug(name);
|
||||
const invalid = slugError(name);
|
||||
// A cosmetic edit that lands on the same slug is still a rename worth doing —
|
||||
// the name is what the customer reads. Only an empty or unchanged name is
|
||||
// nothing to submit.
|
||||
const unchanged = name === currentName.trim();
|
||||
|
||||
async function submit() {
|
||||
setError(undefined);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await onRename(name);
|
||||
setDone(res);
|
||||
setOpen(false);
|
||||
// The input is prefilled with the current name, and the current name
|
||||
// is now this one. Leaving the old text in would make the next open
|
||||
// look like an edit already in progress.
|
||||
setValue(res.name);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The note sits ABOVE the control rather than replacing it. A rename is not
|
||||
// a one-shot action — a customer who mistypes the new name needs the panel
|
||||
// back, and returning early here left them with a success message and no way
|
||||
// to correct it short of a reload.
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{done &&
|
||||
(movesHost ? (
|
||||
<Note tone="warn">
|
||||
<span className="grid gap-2">
|
||||
<span>
|
||||
This instance is now <strong>{done.name}</strong>, at{" "}
|
||||
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and
|
||||
your sign-in does not follow it — you will need to sign in again there.
|
||||
</span>
|
||||
<a
|
||||
href={done.login_url || `https://${hostFor(done.slug)}`}
|
||||
className="justify-self-start font-mono text-[0.78rem] text-accent underline"
|
||||
>
|
||||
Open {hostFor(done.slug)} →
|
||||
</a>
|
||||
</span>
|
||||
</Note>
|
||||
) : (
|
||||
<Note tone="warn">
|
||||
This instance is now <strong>{done.name}</strong>.
|
||||
</Note>
|
||||
))}
|
||||
{open && (
|
||||
<Field
|
||||
label="Instance name"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
error={error ?? (name ? invalid : undefined)}
|
||||
hint={
|
||||
movesHost && name && !invalid ? (
|
||||
<>
|
||||
Moves to <span className="font-mono">{hostFor(derived)}</span>
|
||||
{derived === currentSlug && " — the address does not change"}
|
||||
</>
|
||||
) : (
|
||||
"Letters and digits; everything else becomes a hyphen."
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
|
||||
onClick={() => (open ? submit() : setOpen(true))}
|
||||
>
|
||||
{busy ? "Renaming…" : "Rename instance"}
|
||||
</Button>
|
||||
{open && movesHost && (
|
||||
<span className="text-[0.82rem] text-ink-3">
|
||||
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import clsx from "clsx";
|
||||
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||
|
||||
/*
|
||||
* One table treatment for the whole console.
|
||||
*
|
||||
* There were four: billing, licences, accounts and catalogue each wrote their
|
||||
* own thead, and they disagreed about the head's type size, its tracking,
|
||||
* whether it sat on --panel-2, and whether numbers were tabular. Catalogue's
|
||||
* heads were sentence-case body text. A registry whose columns are set four
|
||||
* ways does not read as one product.
|
||||
*
|
||||
* The head is the keyed idiom — mono, small, uppercase, widely tracked — which
|
||||
* is what a column head is: a key above a value, exactly as the record line is
|
||||
* a key beside one.
|
||||
*
|
||||
* MOBILE. `stack` collapses the table into one card per row below sm, each cell
|
||||
* becoming a label/value pair drawn from TD's `label`. The variants below hang
|
||||
* off a `stacked` class on the <table>, so a table that does not opt in is
|
||||
* untouched at every width.
|
||||
*
|
||||
* It is opt-in rather than automatic because a stacked row whose cells have no
|
||||
* labels is worse than a scrolling one — the values lose the only thing naming
|
||||
* them. Customer screens stack; the staff console's wide registry tables scroll
|
||||
* sideways instead, which is the right trade for eight columns read at a desk.
|
||||
*/
|
||||
|
||||
const STACK = "max-sm:[.stacked_&]:block";
|
||||
|
||||
export function Table({ stack, className, children, ...props }: HTMLAttributes<HTMLTableElement> & { stack?: boolean }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={clsx("w-full border-collapse text-left text-[0.86rem]", stack && "stacked max-sm:block", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function THead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<thead className={clsx("border-b border-rule", "max-sm:[.stacked_&]:hidden", className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function TBody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<tbody className={clsx(STACK, "max-sm:[.stacked_&]:space-y-3 max-sm:[.stacked_&]:p-3", className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
export function TR({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return (
|
||||
<tr
|
||||
className={clsx(
|
||||
"border-b border-rule-soft last:border-0 hover:bg-panel-2",
|
||||
STACK,
|
||||
// Plain bg-panel-2, not an opacity modifier: this app's tokens
|
||||
// are whole colours rather than RGB channels, so `/40` has
|
||||
// nothing to drop an alpha into. web/ stores channels precisely
|
||||
// because it leans on those modifiers; this one must not.
|
||||
"max-sm:[.stacked_&]:rounded max-sm:[.stacked_&]:border max-sm:[.stacked_&]:border-rule max-sm:[.stacked_&]:bg-panel-2 max-sm:[.stacked_&]:p-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface CellProps {
|
||||
/** Right-aligns the cell. For quantities and money, which read down the column. */
|
||||
numeric?: boolean;
|
||||
}
|
||||
|
||||
export function TH({ className, numeric, children, ...props }: ThHTMLAttributes<HTMLTableCellElement> & CellProps) {
|
||||
return (
|
||||
<th
|
||||
className={clsx(
|
||||
"whitespace-nowrap px-4 py-2.5 font-mono text-[0.62rem] font-normal uppercase tracking-[0.13em] text-ink-3",
|
||||
numeric && "text-right",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function TD({ className, numeric, label, children, ...props }: TdHTMLAttributes<HTMLTableCellElement> & CellProps & { label?: string }) {
|
||||
return (
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-3 align-middle",
|
||||
numeric && "text-right tabular-nums",
|
||||
// Stacked, a cell is a label above its value and the right
|
||||
// alignment that made a money column read down the page is
|
||||
// meaningless, so it is dropped.
|
||||
STACK,
|
||||
"max-sm:[.stacked_&]:px-0 max-sm:[.stacked_&]:py-1 max-sm:[.stacked_&]:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label && (
|
||||
<span className="mb-0.5 hidden font-mono text-[0.6rem] uppercase tracking-[0.13em] text-ink-3 max-sm:[.stacked_&]:block">{label}</span>
|
||||
)}
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/** The secondary line under a cell's main value — an ID, a deployment, a date. */
|
||||
export function Sub({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-[0.78rem] text-ink-3">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import clsx from "clsx";
|
||||
import { daysRemaining, formatDate, type LicenceState } from "@/lib/format";
|
||||
|
||||
/*
|
||||
* A licence's life as a measured line: issued at the left, expiry at the right,
|
||||
* today as a notch, the part you have not got yet hatched.
|
||||
*
|
||||
* This replaces a 1px progress rule and a "Renews 19 Aug 2026" caption. The
|
||||
* date is still there, but a date alone makes the reader do the arithmetic that
|
||||
* is the only question this product is ever asked — when does this stop
|
||||
* working. The bar answers it before they read a word.
|
||||
*
|
||||
* The fill takes the state's colour, so the same vocabulary the pill uses
|
||||
* carries through. State is never colour alone here either: the remaining span
|
||||
* is hatched rather than tinted, the notch is a hard edge, and the days-left
|
||||
* figure is written out.
|
||||
*/
|
||||
|
||||
const TONE: Record<LicenceState, string> = {
|
||||
valid: "text-valid",
|
||||
warn: "text-warn",
|
||||
expired: "text-expired",
|
||||
none: "text-accent",
|
||||
};
|
||||
|
||||
function span(issuedAt: string, expiresAt: string) {
|
||||
const start = new Date(issuedAt).getTime();
|
||||
const end = new Date(expiresAt).getTime();
|
||||
const total = end - start;
|
||||
// A licence issued and expiring at the same instant is not a real record,
|
||||
// but it must not divide by zero on the way to being rendered.
|
||||
if (!Number.isFinite(total) || total <= 0) return 100;
|
||||
const elapsed = Date.now() - start;
|
||||
return Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||||
}
|
||||
|
||||
export function TermBar({
|
||||
issuedAt,
|
||||
expiresAt,
|
||||
state,
|
||||
className,
|
||||
}: {
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
state: LicenceState;
|
||||
className?: string;
|
||||
}) {
|
||||
const pct = span(issuedAt, expiresAt);
|
||||
const days = daysRemaining(expiresAt);
|
||||
const expired = days <= 0;
|
||||
|
||||
const remaining = expired
|
||||
? `Expired ${Math.abs(days)} ${Math.abs(days) === 1 ? "day" : "days"} ago`
|
||||
: `${days} ${days === 1 ? "day" : "days"} left`;
|
||||
|
||||
return (
|
||||
<div className={clsx("grid gap-2", TONE[state], className)}>
|
||||
<div className="relative h-[26px] overflow-hidden rounded-sm border border-rule bg-panel-2">
|
||||
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.16]" style={{ width: `${pct}%` }} />
|
||||
{/* The span still to come, drawn as absence rather than as a
|
||||
second colour: it is the thing being bought. */}
|
||||
<span
|
||||
className="absolute inset-y-0 right-0 bg-[repeating-linear-gradient(45deg,transparent_0_5px,var(--rule-soft)_5px_6px)]"
|
||||
style={{ width: `${100 - pct}%` }}
|
||||
/>
|
||||
<span className="absolute -inset-y-px w-0.5 bg-current" style={{ left: `${pct}%` }} />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* On a phone the three ends stack, and the figure someone actually
|
||||
* came for goes first — wrapping a justify-between row left "9 days
|
||||
* left" marooned between two dates in the middle of the stack.
|
||||
*/}
|
||||
<div className="grid gap-1 sm:flex sm:flex-wrap sm:items-baseline sm:justify-between sm:gap-x-4">
|
||||
<span className="order-1 font-mono text-[0.74rem] font-bold tabular-nums sm:order-2">{remaining}</span>
|
||||
<span className="order-2 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3 sm:order-1">Issued {formatDate(issuedAt)}</span>
|
||||
<span className="order-3 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-ink-3">Expires {formatDate(expiresAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* The same measurement at 56px, for a row in a ledger. Licences, Billing and
|
||||
* the staff expiry queue are all lists of terms, and a list of dates cannot be
|
||||
* scanned for "which of these is nearly out" — a list of bars can.
|
||||
*
|
||||
* It carries a text alternative rather than a title: the row it sits in is
|
||||
* being read, not hovered.
|
||||
*/
|
||||
export function TermSpark({ issuedAt, expiresAt, state }: { issuedAt: string; expiresAt: string; state: LicenceState }) {
|
||||
const pct = span(issuedAt, expiresAt);
|
||||
const days = daysRemaining(expiresAt);
|
||||
|
||||
return (
|
||||
<span className={clsx("inline-flex items-center gap-2", TONE[state])}>
|
||||
<span aria-hidden className="relative inline-block h-[9px] w-14 overflow-hidden rounded-sm border border-rule bg-panel-2 align-middle">
|
||||
<span className="absolute inset-y-0 left-0 bg-current opacity-[0.45]" style={{ width: `${pct}%` }} />
|
||||
<span className="absolute inset-y-0 w-px bg-current" style={{ left: `${pct}%` }} />
|
||||
</span>
|
||||
<span className="font-mono text-[0.72rem] tabular-nums">{days <= 0 ? `−${Math.abs(days)}d` : `${days}d`}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+26
-14
@@ -129,6 +129,9 @@ export interface Instance {
|
||||
status: InstanceStatus;
|
||||
current_license?: string;
|
||||
relink_count: number;
|
||||
/** Cloud only, and only until the paid checkout provisions the real row. */
|
||||
placeholder?: boolean;
|
||||
renamed_at?: string;
|
||||
inject_failed_at?: string | null;
|
||||
notices_sent?: string[];
|
||||
created_at: string;
|
||||
@@ -247,13 +250,13 @@ export interface Entitlement {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CustomerUser {
|
||||
user_id: string;
|
||||
account_id: string;
|
||||
email: string;
|
||||
verified_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
/*
|
||||
* Staff and customer screens read the SAME customer_users row, so they share one
|
||||
* type. There used to be a second, narrower CustomerUser for the staff side; it
|
||||
* silently stopped matching the moment account_role was added to the model, and
|
||||
* a subset type cannot warn about a field it never claimed to have.
|
||||
*/
|
||||
export type CustomerUser = AccountUser;
|
||||
|
||||
export interface AuditEntry {
|
||||
actor: string;
|
||||
@@ -288,6 +291,14 @@ export interface StaffInstanceResponse {
|
||||
injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
|
||||
}
|
||||
|
||||
export interface RenameResult {
|
||||
instance_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
/** Empty when APP_LOGIN_URL is unset on the server. */
|
||||
login_url?: string;
|
||||
}
|
||||
|
||||
// --- calls ---------------------------------------------------------------
|
||||
|
||||
export const api = {
|
||||
@@ -296,8 +307,6 @@ export const api = {
|
||||
staffLogin: (email: string, password: string) =>
|
||||
post<Session>("/auth/staff/login", { email, password }),
|
||||
logout: () => post<{ ok: boolean }>("/auth/logout"),
|
||||
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
|
||||
post<{ pending: boolean }>("/auth/signup", payload),
|
||||
verify: (token: string) =>
|
||||
req<{ verified: boolean; needs_password?: boolean }>(
|
||||
`/auth/verify?token=${encodeURIComponent(token)}`,
|
||||
@@ -308,6 +317,8 @@ export const api = {
|
||||
post<Instance>("/api/instances/link", { instance_id, name }),
|
||||
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
|
||||
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/instances/${id}/name`, { name }),
|
||||
// Self-hosted Free: issue the licence on an already-linked instance.
|
||||
claimFree: (id: string) => post<License>(`/api/instances/${id}/claim-free`, {}),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
@@ -319,8 +330,10 @@ export const api = {
|
||||
entitlement: (id: string) =>
|
||||
req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`),
|
||||
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
|
||||
createSelfHosted: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
|
||||
// Paid self-hosted: links (or reuses) the install's real UUID, which the
|
||||
// checkout then names. There is no placeholder to claim afterwards.
|
||||
createSelfHostedCheckout: (instance_id: string, name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { instance_id, name }),
|
||||
// Paid cloud: provisions the real instance the paid webhook then licenses.
|
||||
createCloudCheckout: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/cloud", { name }),
|
||||
@@ -328,9 +341,6 @@ export const api = {
|
||||
id: string,
|
||||
body: { tier: Tier; term: Term; servers: number; features: string[] },
|
||||
) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
|
||||
claimLink: (placeholderId: string, instance_id: string) =>
|
||||
post<{ instance_id: string; warning?: string }>(
|
||||
`/api/instances/${placeholderId}/claim-link`, { instance_id }),
|
||||
billingPortal: () => post<{ url: string }>("/api/billing/portal"),
|
||||
|
||||
accountUsers: () => req<AccountUser[]>("/api/account/users"),
|
||||
@@ -370,6 +380,8 @@ export const api = {
|
||||
post<License>(`/api/staff/instances/${id}/issue`, payload),
|
||||
relink: (id: string, instance_id: string) =>
|
||||
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
|
||||
renameInstance: (id: string, name: string) =>
|
||||
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
|
||||
licenses: (params?: Record<string, string>) =>
|
||||
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
|
||||
plans: () => req<Plan[]>("/api/staff/plans"),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Human wording for licence feature keys.
|
||||
*
|
||||
* One place, because there were two and they disagreed: the staff configurator
|
||||
* rendered every key that was not "console" as "Single sign-on", so adding a
|
||||
* third feature silently mislabelled the checkbox that grants it. A map with a
|
||||
* fallback degrades to the raw key, which is ugly but never wrong.
|
||||
*
|
||||
* Keys must match shared/license/license.go. */
|
||||
export const FEATURE_LABEL: Record<string, string> = {
|
||||
console: "Browser console",
|
||||
oidc: "Single sign-on",
|
||||
vuln_scanning: "Vulnerability scanning",
|
||||
};
|
||||
|
||||
export const FEATURE_DESC: Record<string, string> = {
|
||||
console: "In-browser SSH, RDP and VNC sessions",
|
||||
oidc: "OIDC sign-in for your whole team",
|
||||
vuln_scanning: "Package inventory matched against distribution security advisories",
|
||||
};
|
||||
|
||||
export function featureLabel(key: string): string {
|
||||
return FEATURE_LABEL[key] ?? key;
|
||||
}
|
||||
|
||||
export function featureDesc(key: string): string {
|
||||
return FEATURE_DESC[key] ?? "";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the
|
||||
* host a rename would move an instance to while the customer types.
|
||||
*
|
||||
* It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it
|
||||
* must change in the same commit as the Go one — the same hazard as
|
||||
* web/lib/targets.ts. The preview is a courtesy; the server's 409 is the
|
||||
* boundary, and the two are allowed to disagree without anything breaking.
|
||||
*/
|
||||
|
||||
/** Mirrors provision.MinSlugLength / MaxSlugLength. */
|
||||
export const MIN_SLUG_LENGTH = 3;
|
||||
export const MAX_SLUG_LENGTH = 40;
|
||||
|
||||
/** Mirrors provision.ReservedSlugs. */
|
||||
const RESERVED = new Set([
|
||||
"www", "api", "app", "admin", "auth",
|
||||
"install", "static", "_next", "default",
|
||||
]);
|
||||
|
||||
/*
|
||||
* The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the
|
||||
* customer instance page; those predate this file and are left alone rather than
|
||||
* refactored under a rename change.
|
||||
*/
|
||||
export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk";
|
||||
|
||||
/** Mirrors provision.Slugify. */
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Mirrors provision.BaseSlug's truncation. */
|
||||
export function baseSlug(name: string): string {
|
||||
return slugify(name).slice(0, MAX_SLUG_LENGTH);
|
||||
}
|
||||
|
||||
/** The reason a name cannot become a slug, or undefined when it can. */
|
||||
export function slugError(name: string): string | undefined {
|
||||
const base = slugify(name);
|
||||
if (base.length < MIN_SLUG_LENGTH) {
|
||||
return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`;
|
||||
}
|
||||
if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) {
|
||||
return "That name is reserved.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The host an instance on this slug is reached at. */
|
||||
export function hostFor(slug: string): string {
|
||||
return `${slug}.${INSTANCE_DOMAIN}`;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
// Starts at "system" on both server and first client render so hydration
|
||||
// matches; the real value lands in the effect below. The inline script in
|
||||
// app/layout.tsx has already painted the correct colours by then, so there
|
||||
// is no flash — only this control's own highlight settles a tick late.
|
||||
// is no flash only this control's own highlight settles a tick late.
|
||||
const [pref, setPref] = useState<ThemePref>("system");
|
||||
|
||||
useEffect(() => setPref(readTheme()), []);
|
||||
@@ -51,6 +51,4 @@ export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
* Runs before first paint, so a dark-preferring user never sees a white flash.
|
||||
* Inlined as a string because it has to execute ahead of React.
|
||||
*/
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
|
||||
KEY,
|
||||
)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(KEY)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
|
||||
+1
-1
@@ -3,13 +3,13 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/agent
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.20.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/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigDirByOS(t *testing.T) {
|
||||
d := ConfigDir()
|
||||
if runtime.GOOS == "windows" {
|
||||
if !strings.Contains(strings.ToLower(d), "programdata") {
|
||||
t.Fatalf("windows config dir = %q, want ProgramData path", d)
|
||||
}
|
||||
} else {
|
||||
if d != "/etc/vantage" {
|
||||
t.Fatalf("unix config dir = %q, want /etc/vantage", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,11 @@ func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo str
|
||||
return resp.AgentToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error) {
|
||||
// SyncKeys returns the whole response rather than just the keys: the poll now
|
||||
// also carries CollectPackages, and a second RPC purely to learn one boolean
|
||||
// would be a message every 30 seconds for a value that changes at most when a
|
||||
// licence does.
|
||||
func (c *Client) SyncKeys(serverID, agentToken, version string) (*pb.SyncResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -92,7 +96,34 @@ func (c *Client) SyncKeys(serverID, agentToken, version string) ([]string, error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.PublicKeys, nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ReportPackages sends a package report and returns whether the server wants
|
||||
// the full list. Given a longer deadline than the other unary calls because the
|
||||
// full body is ~150KB on a slow link.
|
||||
func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.ReportPackages(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.NeedFull, nil
|
||||
}
|
||||
|
||||
// ReportWorkloads sends a workload report and returns whether the server wants
|
||||
// the full list.
|
||||
func (c *Client) ReportWorkloads(req *pb.ReportWorkloadsRequest) (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.ReportWorkloads(ctx, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.NeedFull, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
|
||||
@@ -151,3 +182,7 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
return c.client.CommandStream(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error) {
|
||||
return c.client.ProxyStream(ctx)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
package pb
|
||||
|
||||
import (
|
||||
@@ -30,6 +28,45 @@ type SyncRequest struct {
|
||||
|
||||
type SyncResponse struct {
|
||||
PublicKeys []string `json:"public_keys"`
|
||||
// CollectPackages tells the agent whether this instance's licence grants
|
||||
// vulnerability scanning. Absent decodes as false, which is the safe
|
||||
// direction: an older server leaves agents collecting nothing.
|
||||
CollectPackages bool `json:"collect_packages,omitempty"`
|
||||
}
|
||||
|
||||
type OSRelease struct {
|
||||
Family string `json:"family"`
|
||||
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
|
||||
// versions for the same CVE, so a scan without it is guesswork.
|
||||
VersionId string `json:"version_id"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
}
|
||||
|
||||
type InstalledPackage struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Epoch int32 `json:"epoch,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
|
||||
// against "openssl" covers libssl3, openssl and libssl-dev.
|
||||
SourceName string `json:"source_name,omitempty"`
|
||||
}
|
||||
|
||||
// ReportPackagesRequest carries a server's installed package set.
|
||||
//
|
||||
// The agent calls twice at most: first with Packages empty, offering only the
|
||||
// hash. If the server already holds it, NeedFull is false and the ~150KB body
|
||||
// is never sent.
|
||||
type ReportPackagesRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
Os OSRelease `json:"os"`
|
||||
Packages []InstalledPackage `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
type ReportPackagesResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
type UploadKeyRequest struct {
|
||||
@@ -44,8 +81,6 @@ type UploadKeyResponse struct {
|
||||
KeyId string `json:"key_id"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type PackageUpdate struct {
|
||||
Name string `json:"name"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
@@ -60,8 +95,6 @@ type ReportUpdatesRequest struct {
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
|
||||
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
@@ -80,20 +113,19 @@ type PartitionReport struct {
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type InventoryReport struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
RebootRequired bool `json:"reboot_required,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
|
||||
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
@@ -131,6 +163,32 @@ 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"`
|
||||
@@ -139,9 +197,18 @@ type ServerCommand struct {
|
||||
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"`
|
||||
@@ -165,12 +232,14 @@ type GenerateKeyCmd struct {
|
||||
}
|
||||
|
||||
type AgentMessage struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Ready *AgentReady `json:"ready,omitempty"`
|
||||
Result *CommandResult `json:"result,omitempty"`
|
||||
StepResult *StepResult `json:"step_result,omitempty"`
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
|
||||
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
|
||||
}
|
||||
|
||||
type AgentReady struct{}
|
||||
@@ -186,8 +255,7 @@ type RunStepCmd struct {
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
|
||||
|
||||
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -206,8 +274,6 @@ type StepOutputChunk struct {
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
type Vantage_CommandStreamClient interface {
|
||||
Send(*AgentMessage) error
|
||||
Recv() (*ServerCommand, error)
|
||||
@@ -230,8 +296,6 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
type Vantage_CommandStreamServer interface {
|
||||
Send(*ServerCommand) error
|
||||
Recv() (*AgentMessage, error)
|
||||
@@ -254,15 +318,63 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||
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{}
|
||||
@@ -317,6 +429,14 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
|
||||
out := new(ReportPackagesResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
|
||||
out := new(InventoryReportResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
|
||||
@@ -349,3 +469,12 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package pb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Workload registry messages. Hand-written like the rest of this package: the
|
||||
// .proto is the contract, this file is the Go side of it, and the two must be
|
||||
// changed together.
|
||||
|
||||
// Workload is one container or one systemd unit.
|
||||
type Workload struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
Ports []string `json:"ports,omitempty"`
|
||||
Restarts int32 `json:"restarts,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
|
||||
Protected bool `json:"protected,omitempty"`
|
||||
}
|
||||
|
||||
// ReportWorkloadsRequest carries what a server is running.
|
||||
//
|
||||
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
|
||||
// with Workloads empty, and resends with the body only if NeedFull is set.
|
||||
type ReportWorkloadsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Hash string `json:"hash"`
|
||||
DockerOk bool `json:"docker_ok"`
|
||||
DockerError string `json:"docker_error,omitempty"`
|
||||
SystemdOk bool `json:"systemd_ok"`
|
||||
SystemdError string `json:"systemd_error,omitempty"`
|
||||
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
|
||||
// Full marks the second call. It is not inferred from an empty Workloads
|
||||
// slice: a host running nothing sends an empty list as its full report.
|
||||
Full bool `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
type ReportWorkloadsResponse struct {
|
||||
NeedFull bool `json:"need_full"`
|
||||
}
|
||||
|
||||
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
|
||||
// immediately through ReportWorkloads, so there is exactly one writer for the
|
||||
// server_workloads collection rather than two arriving by different routes.
|
||||
type RefreshWorkloadsCmd struct{}
|
||||
|
||||
type ControlWorkloadCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Action string `json:"action"` // start | stop | restart
|
||||
}
|
||||
|
||||
type WorkloadLogsCmd struct {
|
||||
Kind string `json:"kind"`
|
||||
Id string `json:"id"`
|
||||
Tail int32 `json:"tail,omitempty"`
|
||||
}
|
||||
|
||||
type WorkloadLogsResult struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
|
||||
out := new(ReportWorkloadsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -144,11 +144,14 @@ func partitions() []pb.PartitionReport {
|
||||
if syscall.Statfs(fields[1], &st) != nil {
|
||||
continue
|
||||
}
|
||||
total := st.Blocks * uint64(st.Bsize)
|
||||
free := st.Bavail * uint64(st.Bsize)
|
||||
bsize := uint64(st.Bsize)
|
||||
total := st.Blocks * bsize
|
||||
// Bfree, not Bavail: the difference is the root-reserved 5% on ext4,
|
||||
// which is not used space. df counts it the same way.
|
||||
used := (st.Blocks - st.Bfree) * bsize
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
|
||||
TotalBytes: total, UsedBytes: total - free,
|
||||
TotalBytes: total, UsedBytes: used,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//go:build !linux
|
||||
//go:build !linux && !windows
|
||||
|
||||
// Inventory collection is Linux-only. This no-op stands in everywhere else.
|
||||
// Inventory collection has Linux and Windows implementations. This no-op stands
|
||||
// in everywhere else.
|
||||
//
|
||||
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
|
||||
// without it this file compiles on Linux too and collides with collect_linux.go.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
|
||||
// x/sys/windows exposes neither of these two, so they are bound by hand.
|
||||
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
// Windows has no load average. Left at zero; the UI already treats it as
|
||||
// optional because it is omitempty on the wire.
|
||||
|
||||
m := memoryStatus()
|
||||
if m.TotalPhys > m.AvailPhys {
|
||||
r.Memory.UsedBytes = m.TotalPhys - m.AvailPhys
|
||||
}
|
||||
// TotalPageFile is the commit limit — physical memory plus the pagefile —
|
||||
// so the pagefile alone is the difference.
|
||||
swapTotal := sub(m.TotalPageFile, m.TotalPhys)
|
||||
swapUsed := sub(sub(m.TotalPageFile, m.AvailPageFile), sub(m.TotalPhys, m.AvailPhys))
|
||||
if swapUsed > swapTotal {
|
||||
swapUsed = swapTotal
|
||||
}
|
||||
r.SwapUsed = swapUsed
|
||||
|
||||
if includeStatic {
|
||||
r.Memory.TotalBytes = m.TotalPhys
|
||||
r.SwapTotal = swapTotal
|
||||
r.CPU.Model, r.CPU.Cores = cpuStatic()
|
||||
r.Kernel = kernel()
|
||||
r.Partitions = partitions()
|
||||
}
|
||||
}
|
||||
|
||||
func sub(a, b uint64) uint64 {
|
||||
if a > b {
|
||||
return a - b
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type memoryStatusEx struct {
|
||||
Length uint32
|
||||
MemoryLoad uint32
|
||||
TotalPhys uint64
|
||||
AvailPhys uint64
|
||||
TotalPageFile uint64
|
||||
AvailPageFile uint64
|
||||
TotalVirtual uint64
|
||||
AvailVirtual uint64
|
||||
AvailExtendedVirtual uint64
|
||||
}
|
||||
|
||||
func memoryStatus() memoryStatusEx {
|
||||
var m memoryStatusEx
|
||||
m.Length = uint32(unsafe.Sizeof(m))
|
||||
r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m)))
|
||||
if r == 0 {
|
||||
return memoryStatusEx{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func systemTimes() (idle, total uint64, ok bool) {
|
||||
var idleFT, kernelFT, userFT windows.Filetime
|
||||
r, _, _ := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idleFT)),
|
||||
uintptr(unsafe.Pointer(&kernelFT)),
|
||||
uintptr(unsafe.Pointer(&userFT)),
|
||||
)
|
||||
if r == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
ft := func(f windows.Filetime) uint64 {
|
||||
return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime)
|
||||
}
|
||||
// Kernel time already includes idle time, so kernel+user is the whole.
|
||||
return ft(idleFT), ft(kernelFT) + ft(userFT), true
|
||||
}
|
||||
|
||||
func cpuUsage() float64 {
|
||||
i1, t1, ok := systemTimes()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
i2, t2, ok := systemTimes()
|
||||
if !ok || t2 <= t1 {
|
||||
return 0
|
||||
}
|
||||
return (1 - float64(i2-i1)/float64(t2-t1)) * 100
|
||||
}
|
||||
|
||||
func cpuStatic() (model string, cores int) {
|
||||
cores = runtime.NumCPU()
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
||||
`HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer k.Close()
|
||||
if s, _, err := k.GetStringValue("ProcessorNameString"); err == nil {
|
||||
model = s
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func kernel() string {
|
||||
v := windows.RtlGetVersion()
|
||||
return fmt.Sprintf("%d.%d.%d", v.MajorVersion, v.MinorVersion, v.BuildNumber)
|
||||
}
|
||||
|
||||
func partitions() []pb.PartitionReport {
|
||||
buf := make([]uint16, 256)
|
||||
n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0])
|
||||
if err != nil || n == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []pb.PartitionReport
|
||||
for _, root := range splitNullStrings(buf[:n]) {
|
||||
rootPtr, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Fixed disks only: network shares can hang, and removable drives
|
||||
// would appear and vanish between snapshots.
|
||||
if windows.GetDriveType(rootPtr) != windows.DRIVE_FIXED {
|
||||
continue
|
||||
}
|
||||
|
||||
var free, total, totalFree uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(rootPtr, &free, &total, &totalFree); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fsBuf := make([]uint16, 32)
|
||||
var fstype string
|
||||
if err := windows.GetVolumeInformation(rootPtr, nil, 0, nil, nil, nil, &fsBuf[0], uint32(len(fsBuf))); err == nil {
|
||||
fstype = windows.UTF16ToString(fsBuf)
|
||||
}
|
||||
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: root,
|
||||
Mountpoint: root,
|
||||
Fstype: fstype,
|
||||
TotalBytes: total,
|
||||
UsedBytes: total - totalFree,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitNullStrings splits the NUL-separated, double-NUL-terminated block that
|
||||
// GetLogicalDriveStrings writes.
|
||||
func splitNullStrings(b []uint16) []string {
|
||||
var out []string
|
||||
start := 0
|
||||
for i, c := range b {
|
||||
if c != 0 {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
out = append(out, windows.UTF16ToString(b[start:i]))
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OSRelease identifies the distribution well enough to select an advisory
|
||||
// feed. VersionID is not optional: Ubuntu 22.04 and 24.04 publish different
|
||||
// fixed versions for the same CVE.
|
||||
type OSRelease struct {
|
||||
Family string
|
||||
VersionID string
|
||||
Arch string
|
||||
}
|
||||
|
||||
// ParseOSRelease reads the os-release format: KEY=value, one per line, with
|
||||
// values optionally quoted, and # comments.
|
||||
//
|
||||
// The quote stripping handles both ID=ubuntu and ID="rocky", which real
|
||||
// distributions both emit.
|
||||
func ParseOSRelease(r io.Reader) (OSRelease, error) {
|
||||
out := OSRelease{Arch: runtime.GOARCH}
|
||||
sc := bufio.NewScanner(r)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "ID":
|
||||
out.Family = strings.ToLower(val)
|
||||
case "VERSION_ID":
|
||||
out.VersionID = val
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return OSRelease{}, err
|
||||
}
|
||||
if out.Family == "" {
|
||||
return OSRelease{}, errors.New("os-release has no ID")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DetectOS reads /etc/os-release.
|
||||
func DetectOS() (OSRelease, error) {
|
||||
f, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return OSRelease{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
return ParseOSRelease(f)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
const collectTimeout = 2 * time.Minute
|
||||
|
||||
// Collect enumerates installed packages. Linux only: Windows agents are
|
||||
// second-class by design, and vulnerability scanning there needs a different
|
||||
// source, a different collector and a different matcher, all out of scope.
|
||||
//
|
||||
// The format strings below are raw string literals on purpose. The "\t" and
|
||||
// "\n" reach dpkg-query and rpm as two characters each, and those tools do the
|
||||
// interpreting themselves — Go must not consume the escapes first.
|
||||
func Collect() (OSRelease, []Package, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
osrel, err := DetectOS()
|
||||
if err != nil {
|
||||
return OSRelease{}, nil, fmt.Errorf("detect os: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
|
||||
defer cancel()
|
||||
|
||||
switch {
|
||||
case have("dpkg-query"):
|
||||
out, err := run(ctx, "dpkg-query", "-W", "-f",
|
||||
`${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n`)
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseDpkg(out), nil
|
||||
|
||||
case have("rpm"):
|
||||
out, err := run(ctx, "rpm", "-qa", "--qf",
|
||||
`%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`)
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseRPM(out), nil
|
||||
|
||||
case have("apk"):
|
||||
out, err := run(ctx, "apk", "info", "-v")
|
||||
if err != nil {
|
||||
return osrel, nil, err
|
||||
}
|
||||
return osrel, ParseAPK(out), nil
|
||||
|
||||
default:
|
||||
return osrel, nil, fmt.Errorf("no supported package manager found")
|
||||
}
|
||||
}
|
||||
|
||||
func have(bin string) bool {
|
||||
_, err := exec.LookPath(bin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, name, args...).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Package is one installed package as the distribution reports it. Version is
|
||||
// the distribution's own version string, verbatim — never normalised, because
|
||||
// the advisory feeds are keyed on exactly this form.
|
||||
type Package struct {
|
||||
Name string
|
||||
Version string
|
||||
Epoch int
|
||||
Arch string
|
||||
SourceName string
|
||||
}
|
||||
|
||||
// ParseDpkg reads tab-separated output of
|
||||
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n'
|
||||
//
|
||||
// SourceName is why the fourth column is requested at all: Debian and Ubuntu
|
||||
// advisories are keyed on the SOURCE package, so one CVE against "openssl"
|
||||
// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name
|
||||
// alone finds one of the three.
|
||||
//
|
||||
// The fifth column is why "rc" packages do not appear. dpkg-query -W lists
|
||||
// every package dpkg knows about, including ones removed with their config
|
||||
// files left behind — a host that has upgraded its kernel a dozen times reports
|
||||
// a dozen old linux-modules versions that are not on disk, and the oldest of
|
||||
// them sorts first and reads as the installed version. Only "installed" is
|
||||
// installed. An empty status means dpkg did not understand the field, in which
|
||||
// case the line is kept rather than the whole inventory silently vanishing.
|
||||
func ParseDpkg(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
if len(f) > 4 {
|
||||
if s := strings.TrimSpace(f[4]); s != "" && s != "installed" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
p := Package{Name: f[0], Version: f[1], Arch: f[2]}
|
||||
if len(f) > 3 && f[3] != "" {
|
||||
p.SourceName = f[3]
|
||||
} else {
|
||||
p.SourceName = p.Name
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// ParseRPM reads tab-separated output of
|
||||
// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n'
|
||||
func ParseRPM(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
epoch := 0
|
||||
// rpm prints "(none)" rather than omitting the field when a package has
|
||||
// no epoch. That must become 0, not fail the line.
|
||||
if f[1] != "" && f[1] != "(none)" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
epoch = n
|
||||
}
|
||||
}
|
||||
p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]}
|
||||
if len(f) > 4 {
|
||||
p.SourceName = srcRPMName(f[4])
|
||||
}
|
||||
if p.SourceName == "" {
|
||||
p.SourceName = p.Name
|
||||
}
|
||||
pkgs = append(pkgs, p)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping
|
||||
// the trailing ".src.rpm" and then the version and release segments, which are
|
||||
// the last two hyphen-separated fields.
|
||||
func srcRPMName(s string) string {
|
||||
s = strings.TrimSuffix(s, ".src.rpm")
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) <= 2 {
|
||||
return s
|
||||
}
|
||||
return strings.Join(parts[:len(parts)-2], "-")
|
||||
}
|
||||
|
||||
// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line.
|
||||
// Alpine has no separate source package, so SourceName mirrors Name.
|
||||
func ParseAPK(out string) []Package {
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, version := splitAPK(line)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// splitAPK finds the version boundary from the RIGHT. The version is always the
|
||||
// last two hyphen-separated fields ("<version>-r<rev>"), which is reliable
|
||||
// where scanning from the left is not: package names legitimately contain
|
||||
// digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be
|
||||
// found by looking for the first digit.
|
||||
func splitAPK(s string) (name, version string) {
|
||||
last := strings.LastIndex(s, "-")
|
||||
if last <= 0 {
|
||||
return "", ""
|
||||
}
|
||||
prev := strings.LastIndex(s[:last], "-")
|
||||
if prev <= 0 {
|
||||
return "", ""
|
||||
}
|
||||
return s[:prev], s[prev+1:]
|
||||
}
|
||||
|
||||
// Hash fingerprints a package set so an unchanged set never has to be sent.
|
||||
//
|
||||
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
|
||||
// and an ordering-sensitive hash would resend the full ~150KB list every hour
|
||||
// for no reason — a cost visible only as traffic.
|
||||
func Hash(pkgs []Package) string {
|
||||
lines := make([]string, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
h := sha256.New()
|
||||
for _, l := range lines {
|
||||
h.Write([]byte(l))
|
||||
h.Write([]byte("\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package agentproxy relays a single TCP connection between a local service and
|
||||
// the control plane, so a control plane that cannot route to this host's network
|
||||
// can still open a console session.
|
||||
//
|
||||
// The dial host is hardcoded to loopback. The control plane supplies only a
|
||||
// port, and nothing in this package can be made to dial anywhere else.
|
||||
package agentproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
const (
|
||||
loopbackHost = "127.0.0.1"
|
||||
chunkSize = 32 * 1024
|
||||
dialTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Stream is the agent's half of a ProxyStream.
|
||||
type Stream interface {
|
||||
Send(*pb.ProxyClientMsg) error
|
||||
Recv() (*pb.ProxyServerMsg, error)
|
||||
CloseSend() error
|
||||
}
|
||||
|
||||
// Open dials the local port, announces itself on the stream, and relays until
|
||||
// either side ends. A refused dial is reported as an explicit close so the
|
||||
// operator sees a reason rather than a hang.
|
||||
func Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error {
|
||||
conn, dialErr := net.DialTimeout("tcp",
|
||||
net.JoinHostPort(loopbackHost, strconv.Itoa(int(port))), dialTimeout)
|
||||
|
||||
if err := stream.Send(&pb.ProxyClientMsg{Open: &pb.ProxyOpen{
|
||||
ServerId: serverID,
|
||||
AgentToken: agentToken,
|
||||
ProxyId: proxyID,
|
||||
}}); err != nil {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return fmt.Errorf("send open: %w", err)
|
||||
}
|
||||
|
||||
if dialErr != nil {
|
||||
_ = stream.Send(&pb.ProxyClientMsg{Close: &pb.ProxyClose{
|
||||
Reason: "dial_refused: " + dialErr.Error(),
|
||||
}})
|
||||
_ = stream.CloseSend()
|
||||
return fmt.Errorf("dial 127.0.0.1:%d: %w", port, dialErr)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
return relay(conn, stream)
|
||||
}
|
||||
|
||||
func relay(conn net.Conn, stream Stream) error {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
// local service -> control plane
|
||||
go func() {
|
||||
buf := make([]byte, chunkSize)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := make([]byte, n)
|
||||
copy(chunk, buf[:n])
|
||||
if sendErr := stream.Send(&pb.ProxyClientMsg{Data: chunk}); sendErr != nil {
|
||||
errCh <- sendErr
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// control plane -> local service
|
||||
go func() {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if msg.Close != nil {
|
||||
errCh <- fmt.Errorf("server closed relay: %s", msg.Close.Reason)
|
||||
return
|
||||
}
|
||||
if len(msg.Data) > 0 {
|
||||
if _, err := conn.Write(msg.Data); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err := <-errCh
|
||||
_ = conn.Close()
|
||||
_ = stream.CloseSend()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
|
||||
)
|
||||
|
||||
// collectPackagesFlag is written by the 30s key poll and read by the hourly
|
||||
// package loop — two different goroutines, hence the atomic.
|
||||
//
|
||||
// It defaults to false, so an agent that has not yet completed a poll, or is
|
||||
// talking to a server too old to send the field, collects nothing. Off is the
|
||||
// safe default: collecting without a licence costs the customer storage they
|
||||
// are not paying for.
|
||||
var collectPackagesFlag atomic.Bool
|
||||
|
||||
// firstPoll closes once a SyncKeys response has set the flag above.
|
||||
//
|
||||
// Without it the boot-time package report loses a race it can only lose: the
|
||||
// hourly loop starts before the first poll, reads a flag that is still false by
|
||||
// construction, and skips — so a freshly installed agent reports no packages for
|
||||
// an hour and the server shows nothing to scan.
|
||||
// How long the boot package report waits for that first poll. Two poll
|
||||
// intervals plus slack: long enough to cover one failed attempt, short enough
|
||||
// that a dead control plane does not hold the OS-update report hostage.
|
||||
const firstPollWait = 90 * time.Second
|
||||
|
||||
var (
|
||||
firstPoll = make(chan struct{})
|
||||
firstPollOnce sync.Once
|
||||
)
|
||||
|
||||
func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
|
||||
|
||||
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
|
||||
// bounded because this loop also reports OS updates, which do not depend on the
|
||||
// flag at all — a control plane that cannot be polled must not silence those too.
|
||||
func waitFirstPoll(ctx context.Context, limit time.Duration) {
|
||||
t := time.NewTimer(limit)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-firstPoll:
|
||||
case <-t.C:
|
||||
log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
|
||||
|
||||
// reportPackages offers a hash of the installed package set and sends the full
|
||||
// list only if the server does not already hold it.
|
||||
//
|
||||
// It runs on the same hourly cadence as the update check because a package set
|
||||
// changes on roughly the same schedule, and reusing that loop means one timer
|
||||
// rather than two.
|
||||
func reportPackages(client *grpcclient.Client, cfg *config.Config) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
}
|
||||
if !collectPackagesEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
osrel, pkgs, err := packages.Collect()
|
||||
if err != nil {
|
||||
log.Printf("package collection error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
pbOS := pb.OSRelease{
|
||||
Family: osrel.Family,
|
||||
VersionId: osrel.VersionID,
|
||||
Arch: osrel.Arch,
|
||||
}
|
||||
hash := packages.Hash(pkgs)
|
||||
|
||||
// The offer: hash only, no body. On an unchanged host this is the whole
|
||||
// exchange, which is the point of the handshake.
|
||||
needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
Os: pbOS,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ReportPackages offer error: %v", err)
|
||||
return
|
||||
}
|
||||
if !needFull {
|
||||
return
|
||||
}
|
||||
|
||||
pbPkgs := make([]pb.InstalledPackage, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
pbPkgs[i] = pb.InstalledPackage{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Epoch: int32(p.Epoch),
|
||||
Arch: p.Arch,
|
||||
SourceName: p.SourceName,
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := client.ReportPackages(&pb.ReportPackagesRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
Os: pbOS,
|
||||
Packages: pbPkgs,
|
||||
}); err != nil {
|
||||
log.Printf("ReportPackages full error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d installed packages", len(pkgs))
|
||||
}
|
||||
+221
-18
@@ -24,6 +24,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -69,6 +70,8 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
|
||||
go runInventory(ctx, cfg)
|
||||
|
||||
go runWorkloads(ctx, cfg)
|
||||
|
||||
go monitors.Run(ctx, cfg)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
@@ -91,11 +94,19 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
}
|
||||
|
||||
func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
|
||||
resp, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SyncKeys: %w", err)
|
||||
}
|
||||
|
||||
// Stored atomically: the hourly package loop reads this from another
|
||||
// goroutine. Absent on the wire decodes as false, so an older server leaves
|
||||
// collection off rather than on.
|
||||
collectPackagesFlag.Store(resp.CollectPackages)
|
||||
markFirstPoll()
|
||||
|
||||
desired := resp.PublicKeys
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil
|
||||
}
|
||||
@@ -117,9 +128,36 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// How long a command stream must survive before it counts as having worked.
|
||||
// Past this, the next drop is treated as a fresh incident rather than as the
|
||||
// continuation of a run of failures.
|
||||
const streamHealthyAfter = time.Minute
|
||||
|
||||
// Stream staleness. The server beats every 20s, so 70s tolerates three missed
|
||||
// beats before the stream is written off — high enough that a slow network or a
|
||||
// briefly busy server does not cost a reconnect, low enough that an agent is
|
||||
// not uncommandable for minutes after a control-plane restart.
|
||||
const (
|
||||
streamStaleAfter = 70 * time.Second
|
||||
streamStaleCheck = 10 * time.Second
|
||||
|
||||
// How often a healthy stream reports itself. Also the interval at which an
|
||||
// agent talking to a control plane too old to send heartbeats says so —
|
||||
// that agent is running without a watchdog, and the journal should not be
|
||||
// silent about it.
|
||||
pingSummaryInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
func runCommandStream(ctx context.Context, cfg *config.Config) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 2 * time.Minute
|
||||
|
||||
// Two minutes was the old ceiling, and it was reached far too easily. The
|
||||
// command stream is what makes this agent controllable at all: while it is
|
||||
// down, workflows and console sessions fail as "agent offline" even though
|
||||
// SyncKeys keeps polling happily and the fleet list still shows the server
|
||||
// active. A shorter ceiling costs a few reconnect attempts; the old one cost
|
||||
// two minutes of an agent that looks fine and answers nothing.
|
||||
const maxBackoff = 30 * time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -128,22 +166,45 @@ func runCommandStream(ctx context.Context, cfg *config.Config) {
|
||||
default:
|
||||
}
|
||||
|
||||
if err := connectAndHandleStream(ctx, cfg); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
}
|
||||
} else {
|
||||
started := time.Now()
|
||||
err := connectAndHandleStream(ctx, cfg)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// A stream that stayed up is evidence the control plane is reachable,
|
||||
// whatever ended it. Without this the backoff only ever climbed:
|
||||
// connectAndHandleStream returns an error on *every* stream end,
|
||||
// including a healthy one dropped by a routine deploy, so an agent
|
||||
// pinned itself at the ceiling after a handful of ordinary restarts and
|
||||
// stayed there for the rest of its life.
|
||||
if time.Since(started) >= streamHealthyAfter {
|
||||
backoff = time.Second
|
||||
}
|
||||
|
||||
// The uptime is in the line because it is what distinguishes a stream
|
||||
// that never worked from one that ran for hours and was dropped by a
|
||||
// deploy — and it is the same measure that decides whether the backoff
|
||||
// resets, so a reader can see why the delay is what it is.
|
||||
up := time.Since(started).Truncate(time.Second)
|
||||
if err != nil {
|
||||
log.Printf("command stream error after %s: %v, reconnecting in %s", up, err, backoff)
|
||||
} else {
|
||||
log.Printf("command stream closed after %s, reconnecting in %s", up, backoff)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +215,13 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stream, err := client.CommandStream(ctx)
|
||||
// Cancelling this context is what unblocks Recv when the stream has gone
|
||||
// quiet. Without it the watchdog below would have no way to interrupt a
|
||||
// read that is never going to return.
|
||||
streamCtx, abandon := context.WithCancel(ctx)
|
||||
defer abandon()
|
||||
|
||||
stream, err := client.CommandStream(streamCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open stream: %w", err)
|
||||
}
|
||||
@@ -167,7 +234,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
return fmt.Errorf("send auth: %w", err)
|
||||
}
|
||||
|
||||
log.Println("command stream connected")
|
||||
log.Printf("command stream connected to %s", cfg.ServerURL)
|
||||
|
||||
var sendMu sync.Mutex
|
||||
send := func(msg *pb.AgentMessage) error {
|
||||
@@ -176,11 +243,93 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
return stream.Send(msg)
|
||||
}
|
||||
|
||||
// Stream liveness, tracked here rather than left to gRPC keepalive.
|
||||
//
|
||||
// Keepalive operates on the transport, and behind an L7 proxy the transport
|
||||
// ends at the proxy: it answers pings whether or not the server behind it
|
||||
// is still running. A control-plane pod that dies therefore leaves this
|
||||
// agent blocked in Recv on a stream that will never deliver another message
|
||||
// and never error, while the control plane dispatches commands into it and
|
||||
// the operator watches nothing happen.
|
||||
//
|
||||
// The watchdog only arms once a ping has actually been seen. A server too
|
||||
// old to send them must not be treated as dead — that would put the agent
|
||||
// in a reconnect loop against a control plane that is working perfectly.
|
||||
var (
|
||||
lastMu sync.Mutex
|
||||
lastRecv = time.Now()
|
||||
pinged bool
|
||||
beats int
|
||||
)
|
||||
markRecv := func(isPing bool) {
|
||||
lastMu.Lock()
|
||||
lastRecv = time.Now()
|
||||
if isPing {
|
||||
beats++
|
||||
// Logged once per stream, because it is the moment the agent starts
|
||||
// holding the control plane to account: before this the watchdog is
|
||||
// disarmed and a dead stream would go unnoticed indefinitely.
|
||||
if !pinged {
|
||||
pinged = true
|
||||
log.Printf("command stream heartbeat detected, watchdog armed (%s threshold)", streamStaleAfter)
|
||||
}
|
||||
}
|
||||
lastMu.Unlock()
|
||||
}
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(streamStaleCheck)
|
||||
defer t.Stop()
|
||||
|
||||
// Reported periodically rather than per beat: at one every 20s the
|
||||
// journal would be nothing else. The count is what makes a partial
|
||||
// failure visible — beats arriving but fewer than expected is a
|
||||
// different problem from beats stopping altogether.
|
||||
summary := time.NewTicker(pingSummaryInterval)
|
||||
defer summary.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-streamCtx.Done():
|
||||
return
|
||||
case <-summary.C:
|
||||
lastMu.Lock()
|
||||
n, armed := beats, pinged
|
||||
beats = 0
|
||||
lastMu.Unlock()
|
||||
if armed {
|
||||
log.Printf("command stream healthy, %d heartbeats in the last %s", n, pingSummaryInterval)
|
||||
} else {
|
||||
log.Printf("command stream up but sending no heartbeats; "+
|
||||
"control plane predates them, watchdog stays disarmed (last message %s ago)",
|
||||
time.Since(lastRecv).Truncate(time.Second))
|
||||
}
|
||||
case <-t.C:
|
||||
lastMu.Lock()
|
||||
idle, armed := time.Since(lastRecv), pinged
|
||||
lastMu.Unlock()
|
||||
if armed && idle > streamStaleAfter {
|
||||
log.Printf("command stream silent for %s (threshold %s), assuming it is dead and reconnecting",
|
||||
idle.Truncate(time.Second), streamStaleAfter)
|
||||
abandon()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
cmd, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("recv: %w", err)
|
||||
}
|
||||
markRecv(cmd.Ping != nil)
|
||||
|
||||
// Pings carry nothing and are not acknowledged; being received is their
|
||||
// whole purpose.
|
||||
if cmd.Ping != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if cmd.GenerateKey != nil {
|
||||
go handleGenerateKey(cfg, cmd)
|
||||
@@ -197,6 +346,18 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(cmd)
|
||||
}
|
||||
if cmd.OpenProxy != nil {
|
||||
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
|
||||
}
|
||||
if cmd.RefreshWorkloads != nil {
|
||||
go handleRefreshWorkloads(cfg)
|
||||
}
|
||||
if cmd.ControlWorkload != nil {
|
||||
go handleControlWorkload(send, cfg, cmd.CommandId, cmd.ControlWorkload)
|
||||
}
|
||||
if cmd.WorkloadLogs != nil {
|
||||
go handleWorkloadLogs(send, cfg, cmd.CommandId, cmd.WorkloadLogs)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
@@ -253,8 +414,17 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d available OS updates", len(pkgs))
|
||||
|
||||
// Same hourly cadence, same connection. A package set changes on
|
||||
// roughly the schedule available updates do, so this needs no timer of
|
||||
// its own.
|
||||
reportPackages(client, cfg)
|
||||
}
|
||||
|
||||
// The boot round only: after this the flag has long been set, and every
|
||||
// later tick is an hour past a poll that runs every 30s.
|
||||
waitFirstPoll(ctx, firstPollWait)
|
||||
|
||||
doCheck()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -280,6 +450,16 @@ func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = cfg.ServerID
|
||||
r.AgentToken = cfg.AgentToken
|
||||
// Static snapshots only — every 15 minutes, not every 30 seconds. On
|
||||
// Windows this spawns a PowerShell process, which is not something to
|
||||
// do twice a minute forever, and a host rebooted by hand clearing the
|
||||
// flag within a quarter of an hour is soon enough.
|
||||
//
|
||||
// Computed here rather than inside inventory.Collect so the inventory
|
||||
// package gains no dependency on updates.
|
||||
if static {
|
||||
r.RebootRequired = updates.RebootRequired()
|
||||
}
|
||||
if err := client.ReportInventory(r); err != nil {
|
||||
log.Printf("report inventory: %v", err)
|
||||
}
|
||||
@@ -326,6 +506,29 @@ func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
|
||||
}
|
||||
|
||||
// handleOpenProxy relays one console connection. It uses its own gRPC
|
||||
// connection so console traffic never shares a stream with commands, key sync
|
||||
// or workflow output.
|
||||
func handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("proxy %s: dial control plane: %v", cmd.ProxyId, err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
stream, err := client.ProxyStream(ctx)
|
||||
if err != nil {
|
||||
log.Printf("proxy %s: open stream: %v", cmd.ProxyId, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("proxy %s: relaying 127.0.0.1:%d", cmd.ProxyId, cmd.Port)
|
||||
if err := agentproxy.Open(stream, cfg.ServerID, cfg.AgentToken, cmd.ProxyId, cmd.Port); err != nil {
|
||||
log.Printf("proxy %s: %v", cmd.ProxyId, err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package agentsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/workloads"
|
||||
)
|
||||
|
||||
// workloadInterval is the report cadence. Sixty seconds is affordable because
|
||||
// an unchanged list costs one small offer message, not the body.
|
||||
const workloadInterval = 60 * time.Second
|
||||
|
||||
// runWorkloads reports what this host runs, on its own ticker.
|
||||
func runWorkloads(ctx context.Context, cfg *config.Config) {
|
||||
reportWorkloads(cfg)
|
||||
|
||||
ticker := time.NewTicker(workloadInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reportWorkloads offers a hash of the current workload set and sends the full
|
||||
// list only if the server does not already hold it.
|
||||
//
|
||||
// This is the ONLY writer of the server_workloads collection. RefreshWorkloadsCmd
|
||||
// calls straight into here rather than answering with data of its own.
|
||||
func reportWorkloads(cfg *config.Config) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
res := workloads.Collect(ctx)
|
||||
hash := workloads.Hash(res.Workloads)
|
||||
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("workload report dial error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
base := func() *pb.ReportWorkloadsRequest {
|
||||
return &pb.ReportWorkloadsRequest{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Hash: hash,
|
||||
DockerOk: res.DockerOK,
|
||||
DockerError: res.DockerError,
|
||||
SystemdOk: res.SystemdOK,
|
||||
SystemdError: res.SystemdError,
|
||||
}
|
||||
}
|
||||
|
||||
// The offer: hash only, no body. On an unchanged host this is the whole
|
||||
// exchange, which is the point of the handshake.
|
||||
needFull, err := client.ReportWorkloads(base())
|
||||
if err != nil {
|
||||
log.Printf("ReportWorkloads offer error: %v", err)
|
||||
return
|
||||
}
|
||||
if !needFull {
|
||||
return
|
||||
}
|
||||
|
||||
req := base()
|
||||
req.Full = true
|
||||
req.Workloads = make([]pb.Workload, len(res.Workloads))
|
||||
for i, w := range res.Workloads {
|
||||
req.Workloads[i] = pb.Workload{
|
||||
Kind: w.Kind,
|
||||
Id: w.ID,
|
||||
Name: w.Name,
|
||||
State: w.State,
|
||||
Health: w.Health,
|
||||
Image: w.Image,
|
||||
Stack: w.Stack,
|
||||
Ports: w.Ports,
|
||||
Restarts: int32(w.Restarts),
|
||||
Protected: w.Protected,
|
||||
}
|
||||
if !w.StartedAt.IsZero() {
|
||||
req.Workloads[i].StartedAt = w.StartedAt.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := client.ReportWorkloads(req); err != nil {
|
||||
log.Printf("ReportWorkloads error: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("reported %d workload(s)", len(res.Workloads))
|
||||
}
|
||||
|
||||
// handleRefreshWorkloads makes the agent report immediately. It sends nothing
|
||||
// back beyond the stream ack: the refresh is a nudge, not a channel, so there
|
||||
// is one writer for the collection rather than two.
|
||||
func handleRefreshWorkloads(cfg *config.Config) {
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
|
||||
// handleControlWorkload starts, stops or restarts a workload and answers with
|
||||
// the ordinary CommandResult.
|
||||
//
|
||||
// The agent's own protected check inside workloads.Control is the boundary; the
|
||||
// Protected flag it reports is only there so the UI can grey the button.
|
||||
func handleControlWorkload(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.ControlWorkloadCmd) {
|
||||
err := workloads.Control(context.Background(), cmd.Kind, cmd.Id, cmd.Action)
|
||||
|
||||
res := &pb.CommandResult{CommandId: commandID, Success: err == nil}
|
||||
if err != nil {
|
||||
res.Message = err.Error()
|
||||
log.Printf("workload %s %s failed (cmd=%s): %v", cmd.Action, cmd.Id, commandID, err)
|
||||
} else {
|
||||
res.Message = cmd.Action + " " + cmd.Id + " ok"
|
||||
}
|
||||
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
Result: res,
|
||||
})
|
||||
|
||||
// Report straight away on success so the UI's refetch shows the new state
|
||||
// rather than the old one.
|
||||
if err == nil {
|
||||
reportWorkloads(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func handleWorkloadLogs(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.WorkloadLogsCmd) {
|
||||
text, truncated, err := workloads.Logs(context.Background(), cmd.Kind, cmd.Id, int(cmd.Tail))
|
||||
res := &pb.WorkloadLogsResult{CommandId: commandID, Text: text, Truncated: truncated}
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
}
|
||||
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
WorkloadLogsResult: res,
|
||||
})
|
||||
}
|
||||
@@ -1,238 +1,24 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PackageUpdate is one pending update. On Linux it is a package with a version
|
||||
// on each side. On Windows CurrentVersion is empty and NewVersion carries the
|
||||
// KB article ID: a Windows update is not a version bump of a named package,
|
||||
// and inventing a current version would put a wrong string in front of an
|
||||
// operator.
|
||||
type PackageUpdate struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
NewVersion string
|
||||
}
|
||||
|
||||
func detectPM() string {
|
||||
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
if pm == "apt-get" {
|
||||
return "apt"
|
||||
}
|
||||
return pm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// CheckAvailable lists pending OS updates.
|
||||
func CheckAvailable() ([]PackageUpdate, error) { return checkAvailable() }
|
||||
|
||||
// ApplyAll installs every pending update. It never reboots: a control plane
|
||||
// silently restarting a production server is unrecoverable from the UI, so the
|
||||
// reboot stays a decision a person or a workflow makes. RebootRequired reports
|
||||
// when one is owed.
|
||||
func ApplyAll() error { return applyAll() }
|
||||
|
||||
|
||||
func CheckAvailable() ([]PackageUpdate, error) {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
return checkApt()
|
||||
case "dnf":
|
||||
return checkDnfYum("dnf")
|
||||
case "yum":
|
||||
return checkDnfYum("yum")
|
||||
case "pacman":
|
||||
return checkPacman()
|
||||
case "zypper":
|
||||
return checkZypper()
|
||||
case "apk":
|
||||
return checkApk()
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func ApplyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
|
||||
case "dnf":
|
||||
return exec.Command("dnf", "upgrade", "-y").Run()
|
||||
case "yum":
|
||||
return exec.Command("yum", "upgrade", "-y").Run()
|
||||
case "pacman":
|
||||
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
|
||||
case "zypper":
|
||||
return exec.Command("zypper", "update", "-y").Run()
|
||||
case "apk":
|
||||
return exec.Command("apk", "upgrade").Run()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkApt() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
|
||||
|
||||
out, err := exec.Command("apt", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.Contains(line, "[upgradable from:") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.SplitN(parts[0], "/", 2)[0]
|
||||
newVer := parts[1]
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
|
||||
rest := line[idx+len("upgradable from: "):]
|
||||
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkDnfYum(pm string) ([]PackageUpdate, error) {
|
||||
cmd := exec.Command(pm, "check-update")
|
||||
out, err := cmd.Output()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
pastHeader := false
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !pastHeader {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pastHeader = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.SplitN(parts[0], ".", 2)[0]
|
||||
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkPacman() ([]PackageUpdate, error) {
|
||||
out, _ := exec.Command("pacman", "-Qu").Output()
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
parts := strings.Fields(scanner.Text())
|
||||
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkZypper() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("zypper", "list-updates").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{
|
||||
Name: strings.TrimSpace(parts[2]),
|
||||
CurrentVersion: strings.TrimSpace(parts[3]),
|
||||
NewVersion: strings.TrimSpace(parts[4]),
|
||||
})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkApk() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("apk", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.Contains(line, "[upgradable") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
pkgVer := parts[0]
|
||||
name := apkName(pkgVer)
|
||||
newVer := apkVersion(pkgVer)
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
|
||||
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
|
||||
rest = strings.TrimSuffix(rest, "]")
|
||||
oldVer = apkVersion(strings.TrimSpace(rest))
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func apkName(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var name []string
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
break
|
||||
}
|
||||
name = append(name, p)
|
||||
}
|
||||
return strings.Join(name, "-")
|
||||
}
|
||||
|
||||
func apkVersion(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var ver []string
|
||||
inVer := false
|
||||
for _, p := range parts {
|
||||
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
inVer = true
|
||||
}
|
||||
if inVer {
|
||||
ver = append(ver, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(ver, "-")
|
||||
}
|
||||
// RebootRequired reports whether this host is waiting on a restart.
|
||||
func RebootRequired() bool { return rebootRequired() }
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func detectPM() string {
|
||||
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
if pm == "apt-get" {
|
||||
return "apt"
|
||||
}
|
||||
return pm
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
func checkAvailable() ([]PackageUpdate, error) {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
return checkApt()
|
||||
case "dnf":
|
||||
return checkDnfYum("dnf")
|
||||
case "yum":
|
||||
return checkDnfYum("yum")
|
||||
case "pacman":
|
||||
return checkPacman()
|
||||
case "zypper":
|
||||
return checkZypper()
|
||||
case "apk":
|
||||
return checkApk()
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func applyAll() error {
|
||||
switch detectPM() {
|
||||
case "apt":
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
|
||||
case "dnf":
|
||||
return exec.Command("dnf", "upgrade", "-y").Run()
|
||||
case "yum":
|
||||
return exec.Command("yum", "upgrade", "-y").Run()
|
||||
case "pacman":
|
||||
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
|
||||
case "zypper":
|
||||
return exec.Command("zypper", "update", "-y").Run()
|
||||
case "apk":
|
||||
return exec.Command("apk", "upgrade").Run()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// rebootRequired reads what the distributions themselves record. Debian and
|
||||
// Ubuntu drop a file; the RPM family answers through needs-restarting, whose
|
||||
// exit code is 1 when a reboot is owed and 0 when it is not.
|
||||
func rebootRequired() bool {
|
||||
if _, err := os.Stat("/var/run/reboot-required"); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := exec.LookPath("dnf"); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "dnf", "needs-restarting", "-r").Run(); err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkApt() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
|
||||
|
||||
out, err := exec.Command("apt", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.Contains(line, "[upgradable from:") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.SplitN(parts[0], "/", 2)[0]
|
||||
newVer := parts[1]
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
|
||||
rest := line[idx+len("upgradable from: "):]
|
||||
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkDnfYum(pm string) ([]PackageUpdate, error) {
|
||||
cmd := exec.Command(pm, "check-update")
|
||||
out, err := cmd.Output()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
pastHeader := false
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !pastHeader {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pastHeader = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.SplitN(parts[0], ".", 2)[0]
|
||||
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkPacman() ([]PackageUpdate, error) {
|
||||
out, _ := exec.Command("pacman", "-Qu").Output()
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
parts := strings.Fields(scanner.Text())
|
||||
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkZypper() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("zypper", "list-updates").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, PackageUpdate{
|
||||
Name: strings.TrimSpace(parts[2]),
|
||||
CurrentVersion: strings.TrimSpace(parts[3]),
|
||||
NewVersion: strings.TrimSpace(parts[4]),
|
||||
})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func checkApk() ([]PackageUpdate, error) {
|
||||
out, err := exec.Command("apk", "list", "--upgradable").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []PackageUpdate
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.Contains(line, "[upgradable") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 1 {
|
||||
continue
|
||||
}
|
||||
pkgVer := parts[0]
|
||||
name := apkName(pkgVer)
|
||||
newVer := apkVersion(pkgVer)
|
||||
oldVer := ""
|
||||
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
|
||||
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
|
||||
rest = strings.TrimSuffix(rest, "]")
|
||||
oldVer = apkVersion(strings.TrimSpace(rest))
|
||||
}
|
||||
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func apkName(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var name []string
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
break
|
||||
}
|
||||
name = append(name, p)
|
||||
}
|
||||
return strings.Join(name, "-")
|
||||
}
|
||||
|
||||
func apkVersion(pkgVer string) string {
|
||||
parts := strings.Split(pkgVer, "-")
|
||||
var ver []string
|
||||
inVer := false
|
||||
for _, p := range parts {
|
||||
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
|
||||
inVer = true
|
||||
}
|
||||
if inVer {
|
||||
ver = append(ver, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(ver, "-")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
|
||||
// without it this file compiles on Linux too and collides with updates_linux.go.
|
||||
package updates
|
||||
|
||||
func checkAvailable() ([]PackageUpdate, error) { return nil, nil }
|
||||
func applyAll() error { return nil }
|
||||
func rebootRequired() bool { return false }
|
||||
@@ -0,0 +1,117 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
const (
|
||||
// The first search after a boot contacts Microsoft Update (or WSUS) and is
|
||||
// routinely slow. Ten minutes is not generous, it is realistic.
|
||||
searchTimeout = 10 * time.Minute
|
||||
|
||||
// A patch-Tuesday cumulative genuinely takes this long to download and
|
||||
// install on a modest server.
|
||||
applyTimeout = 60 * time.Minute
|
||||
|
||||
rebootTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
// The Windows Update COM API is used rather than the PSWindowsUpdate module: it
|
||||
// is present on every supported Windows, needs no PowerShell Gallery install,
|
||||
// and works unchanged against a WSUS server on an air-gapped fleet. The agent
|
||||
// runs as LocalSystem, which holds the rights it requires.
|
||||
const searchScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
|
||||
$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
||||
$rows = @()
|
||||
foreach ($u in $result.Updates) {
|
||||
$ids = @($u.KBArticleIDs)
|
||||
$kb = ''
|
||||
if ($ids.Count -gt 0) { $kb = [string]$ids[0] }
|
||||
$rows += [pscustomobject]@{ title = [string]$u.Title; kb = $kb }
|
||||
}
|
||||
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
const applyScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$session = New-Object -ComObject Microsoft.Update.Session
|
||||
$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
||||
|
||||
$batch = New-Object -ComObject Microsoft.Update.UpdateColl
|
||||
foreach ($u in $result.Updates) {
|
||||
if ($u.InstallationBehavior.CanRequestUserInput) { continue }
|
||||
if (-not $u.EulaAccepted) {
|
||||
try { $u.AcceptEula() } catch { continue }
|
||||
}
|
||||
$null = $batch.Add($u)
|
||||
}
|
||||
|
||||
if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 }
|
||||
|
||||
$downloader = $session.CreateUpdateDownloader()
|
||||
$downloader.Updates = $batch
|
||||
$null = $downloader.Download()
|
||||
|
||||
$installer = $session.CreateUpdateInstaller()
|
||||
$installer.Updates = $batch
|
||||
$r = $installer.Install()
|
||||
|
||||
Write-Output ('resultcode=' + $r.ResultCode)
|
||||
# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this
|
||||
# process must exit non-zero so the agent logs a failure rather than an ack.
|
||||
if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 }
|
||||
exit 0
|
||||
`
|
||||
|
||||
const rebootScript = `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$si = New-Object -ComObject Microsoft.Update.SystemInfo
|
||||
if ($si.RebootRequired) { Write-Output 'true'; exit 0 }
|
||||
$keys = @(
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending',
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
|
||||
)
|
||||
foreach ($k in $keys) { if (Test-Path $k) { Write-Output 'true'; exit 0 } }
|
||||
$sm = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations
|
||||
if ($sm -and $sm.PendingFileRenameOperations) { Write-Output 'true'; exit 0 }
|
||||
Write-Output 'false'
|
||||
`
|
||||
|
||||
func checkAvailable() ([]PackageUpdate, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), searchTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := winexec.Run(ctx, searchScript)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("windows update search: %w", err)
|
||||
}
|
||||
return parseUpdateSearch(out)
|
||||
}
|
||||
|
||||
func applyAll() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), applyTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := winexec.Run(ctx, applyScript); err != nil {
|
||||
return fmt.Errorf("windows update install: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rebootRequired() bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rebootTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := winexec.Run(ctx, rebootScript)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(out) == "true"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// winUpdate is one row of the Windows Update searcher's output, in the shape
|
||||
// searchScript emits it.
|
||||
type winUpdate struct {
|
||||
Title string `json:"title"`
|
||||
KB string `json:"kb"`
|
||||
}
|
||||
|
||||
// parseUpdateSearch reads the searcher's JSON.
|
||||
//
|
||||
// It carries no build tag on purpose: this is the half of the Windows update
|
||||
// path that can be tested on a development machine, and the agent module has no
|
||||
// Windows CI.
|
||||
func parseUpdateSearch(jsonText string) ([]PackageUpdate, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rows []winUpdate
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
// ConvertTo-Json renders a one-element array as a bare object.
|
||||
var one winUpdate
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = []winUpdate{one}
|
||||
}
|
||||
|
||||
out := make([]PackageUpdate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
u := PackageUpdate{Name: r.Title}
|
||||
if kb := strings.TrimSpace(r.KB); kb != "" {
|
||||
if !strings.HasPrefix(strings.ToUpper(kb), "KB") {
|
||||
kb = "KB" + kb
|
||||
}
|
||||
u.NewVersion = kb
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package updates
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUpdateSearchArray(t *testing.T) {
|
||||
in := `[{"title":"2026-08 Cumulative Update for Windows Server 2022","kb":"5034123"},
|
||||
{"title":"Windows Malicious Software Removal Tool","kb":"890830"}]`
|
||||
|
||||
got, err := parseUpdateSearch(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpdateSearch: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d updates, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "2026-08 Cumulative Update for Windows Server 2022" {
|
||||
t.Errorf("Name = %q", got[0].Name)
|
||||
}
|
||||
if got[0].NewVersion != "KB5034123" {
|
||||
t.Errorf("NewVersion = %q, want KB5034123", got[0].NewVersion)
|
||||
}
|
||||
if got[0].CurrentVersion != "" {
|
||||
t.Errorf("CurrentVersion = %q, want empty", got[0].CurrentVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// PowerShell 5.1's ConvertTo-Json collapses a one-element array into a bare
|
||||
// object. A host with exactly one pending update is common, and a parser that
|
||||
// only accepts arrays reports it as zero.
|
||||
func TestParseUpdateSearchSingleObject(t *testing.T) {
|
||||
got, err := parseUpdateSearch(`{"title":"Security Intelligence Update","kb":"2267602"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpdateSearch: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].NewVersion != "KB2267602" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpdateSearchNoKB(t *testing.T) {
|
||||
got, err := parseUpdateSearch(`[{"title":"Driver update for Contoso NIC","kb":""}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpdateSearch: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].NewVersion != "" {
|
||||
t.Fatalf("got %+v, want one update with an empty NewVersion", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty result set is "nothing pending", not a parse failure.
|
||||
func TestParseUpdateSearchEmpty(t *testing.T) {
|
||||
for _, in := range []string{"", " \r\n", "[]", "null"} {
|
||||
got, err := parseUpdateSearch(in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUpdateSearch(%q): %v", in, err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("parseUpdateSearch(%q) = %+v, want none", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A KB already carrying its prefix must not become KBKB5034123.
|
||||
func TestParseUpdateSearchPrefixedKB(t *testing.T) {
|
||||
got, _ := parseUpdateSearch(`[{"title":"x","kb":"KB5034123"}]`)
|
||||
if got[0].NewVersion != "KB5034123" {
|
||||
t.Fatalf("NewVersion = %q", got[0].NewVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package winexec runs PowerShell on Windows hosts.
|
||||
//
|
||||
// It exists because three subsystems — updates, workload collection and
|
||||
// workload logs — all need the same invocation, and because getting a
|
||||
// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own
|
||||
// parser is a problem worth solving once.
|
||||
package winexec
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// EncodeCommand renders a script for powershell.exe -EncodedCommand: UTF-16LE,
|
||||
// no byte-order mark, base64. This is deliberately free of build tags so it is
|
||||
// tested on a Linux development machine like every other pure function here.
|
||||
func EncodeCommand(script string) string {
|
||||
units := utf16.Encode([]rune(script))
|
||||
b := make([]byte, 0, len(units)*2)
|
||||
for _, u := range units {
|
||||
b = append(b, byte(u), byte(u>>8))
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package winexec
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEncodeCommand(t *testing.T) {
|
||||
// "hi" as UTF-16LE is 68 00 69 00, which base64-encodes to aABpAA==.
|
||||
if got := EncodeCommand("hi"); got != "aABpAA==" {
|
||||
t.Fatalf("EncodeCommand(hi) = %q, want aABpAA==", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeCommandMultiline(t *testing.T) {
|
||||
// Only that it round-trips through the same encoding PowerShell expects:
|
||||
// every ASCII byte followed by a zero byte, no BOM.
|
||||
got := EncodeCommand("a\nb")
|
||||
want := "YQAKAGIA"
|
||||
if got != want {
|
||||
t.Fatalf("EncodeCommand = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package winexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Run executes a PowerShell script and returns its stdout.
|
||||
//
|
||||
// powershell.exe rather than pwsh: everything this agent runs through here
|
||||
// touches Windows Update COM or CIM, both of which are most reliable under
|
||||
// Windows PowerShell 5.1, and 5.1 is present on every supported Windows while
|
||||
// pwsh is an optional install.
|
||||
func Run(ctx context.Context, script string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "powershell.exe",
|
||||
"-NoProfile", "-NonInteractive", "-EncodedCommand", EncodeCommand(script))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Checked before the ExitError/stderr branch: CommandContext kills the
|
||||
// process on timeout, and that kill can itself produce an ExitError
|
||||
// carrying stderr text, so a genuine timeout would otherwise surface
|
||||
// as that stderr instead of the "timed out" message callers match on.
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return "", fmt.Errorf("powershell: timed out")
|
||||
}
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return "", fmt.Errorf("powershell: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrProtected is returned for a workload the agent will not act on.
|
||||
var ErrProtected = errors.New("workload is protected")
|
||||
|
||||
// controlTimeout bounds a stop that may never finish on its own. `docker stop`
|
||||
// waits on a container that may ignore SIGTERM, and both systemctl and
|
||||
// Stop-Service block for as long as the unit's own stop timeout says. A
|
||||
// timeout must return a real error rather than an ack implying success.
|
||||
const controlTimeout = 90 * time.Second
|
||||
|
||||
// isProtected reports whether the agent refuses to act on this workload.
|
||||
//
|
||||
// The refusal lives here, in the agent, and not in the control plane. As with
|
||||
// the console relay hardcoding 127.0.0.1 agent-side: the control plane may name
|
||||
// a target, but the agent decides what it will do to itself. A server-side
|
||||
// denylist alone would be bypassed by the next dispatch path someone adds.
|
||||
func isProtected(kind, id, name string) bool {
|
||||
if kind == "unit" {
|
||||
return isProtectedUnit(id, name)
|
||||
}
|
||||
if ownContainerID == "" {
|
||||
return false
|
||||
}
|
||||
// Container IDs are commonly abbreviated to 12 characters; compare on the
|
||||
// shorter of the two so a short id still matches a full one.
|
||||
return strings.HasPrefix(ownContainerID, id) || strings.HasPrefix(id, ownContainerID)
|
||||
}
|
||||
|
||||
// markProtected stamps the flag onto a collected list so the UI can render the
|
||||
// action disabled with a reason.
|
||||
func markProtected(wls []Workload) {
|
||||
for i := range wls {
|
||||
wls[i].Protected = isProtected(wls[i].Kind, wls[i].ID, wls[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Control starts, stops or restarts a workload.
|
||||
func Control(ctx context.Context, kind, id, action string) error {
|
||||
switch action {
|
||||
case "start", "stop", "restart":
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
|
||||
// Checked before anything else happens, and checked here rather than only
|
||||
// on the server. See isProtected.
|
||||
if isProtected(kind, id, strings.TrimSuffix(id, ".service")) {
|
||||
return fmt.Errorf("%w: %s", ErrProtected, id)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, controlTimeout)
|
||||
defer cancel()
|
||||
|
||||
return controlPlatform(ctx, kind, id, action)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AgentUnit is the systemd unit this agent runs as.
|
||||
const AgentUnit = "vantage-agent.service"
|
||||
|
||||
// ownContainerID is read once: the container this agent runs in, if any.
|
||||
var ownContainerID = detectOwnContainer()
|
||||
|
||||
var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)
|
||||
|
||||
// detectOwnContainer returns this process's container ID, or "" on a host
|
||||
// install. The agent is normally a systemd service, so "" is the common case;
|
||||
// this exists so containerising it later cannot silently remove the guard.
|
||||
func detectOwnContainer() string {
|
||||
b, err := os.ReadFile("/proc/self/cgroup")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if m := cgroupContainerRe.FindString(string(b)); m != "" {
|
||||
return m
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isProtectedUnit(id, name string) bool {
|
||||
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
|
||||
}
|
||||
|
||||
func controlPlatform(ctx context.Context, kind, id, action string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", action, id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "systemctl", action, id)
|
||||
default:
|
||||
return fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
// AgentUnit is the service this agent runs as — the NSSM service name written
|
||||
// by installer/setup.ps1. Change one, change the other.
|
||||
const AgentUnit = "VantageAgent"
|
||||
|
||||
// A Windows agent is never itself in a container; the Linux build reads
|
||||
// /proc/self/cgroup, and there is no equivalent question to ask here.
|
||||
var ownContainerID = ""
|
||||
|
||||
// Windows service names are case-insensitive, so the comparison must be too.
|
||||
func isProtectedUnit(id, name string) bool {
|
||||
return strings.EqualFold(id, AgentUnit) || strings.EqualFold(name, AgentUnit)
|
||||
}
|
||||
|
||||
func controlPlatform(ctx context.Context, kind, id, action string) error {
|
||||
switch kind {
|
||||
case "container":
|
||||
// Docker behaves identically on Windows, so this path is shared in
|
||||
// spirit with the Linux one rather than routed through PowerShell.
|
||||
cmd := exec.CommandContext(ctx, "docker", action, id)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
|
||||
case "unit":
|
||||
// -Force is required: Stop-Service without it refuses outright when
|
||||
// another service depends on the target, and that refusal reads to an
|
||||
// operator as a silent no-op.
|
||||
//
|
||||
// sc.exe is avoided because it returns before the operation completes,
|
||||
// which turns a timeout into a false success.
|
||||
var verb string
|
||||
switch action {
|
||||
case "start":
|
||||
verb = "Start-Service"
|
||||
case "stop":
|
||||
verb = "Stop-Service"
|
||||
case "restart":
|
||||
verb = "Restart-Service"
|
||||
default:
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
|
||||
script := "$ErrorActionPreference='Stop'\n" + verb + " -Name " + psQuote(id)
|
||||
if action != "start" {
|
||||
script += " -Force"
|
||||
}
|
||||
|
||||
if _, err := winexec.Run(ctx, script); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
|
||||
}
|
||||
return fmt.Errorf("%s %s: %w", action, id, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Workload is one container or one systemd unit, agent-side. It mirrors
|
||||
// models.Workload on the server.
|
||||
type Workload struct {
|
||||
Kind string
|
||||
ID string
|
||||
Name string
|
||||
State string
|
||||
Health string
|
||||
Image string
|
||||
Stack string
|
||||
Ports []string
|
||||
Restarts int
|
||||
StartedAt time.Time
|
||||
Protected bool
|
||||
}
|
||||
|
||||
const dockerTimeout = 30 * time.Second
|
||||
|
||||
// dockerInspect is the subset of `docker inspect` output we read.
|
||||
//
|
||||
// We use inspect rather than `docker ps --format '{{json .}}'` because ps
|
||||
// reports health and uptime inside a human Status string — "Up 2 hours
|
||||
// (healthy)" — and anything built on that is parsing English that is
|
||||
// localised, reworded between releases, and silently different for a paused or
|
||||
// restarting container. inspect gives typed fields instead.
|
||||
type dockerInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
State struct {
|
||||
Status string `json:"Status"`
|
||||
StartedAt string `json:"StartedAt"`
|
||||
Restarting bool `json:"Restarting"`
|
||||
Health *struct {
|
||||
Status string `json:"Status"`
|
||||
} `json:"Health"`
|
||||
} `json:"State"`
|
||||
Config struct {
|
||||
Image string `json:"Image"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
} `json:"Config"`
|
||||
RestartCount int `json:"RestartCount"`
|
||||
NetworkSettings struct {
|
||||
Ports map[string][]struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
} `json:"Ports"`
|
||||
} `json:"NetworkSettings"`
|
||||
}
|
||||
|
||||
// collectDocker enumerates containers. It returns ok=false with an empty error
|
||||
// string when Docker is simply not installed — the common case on this fleet,
|
||||
// and not a fault.
|
||||
func collectDocker(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return nil, false, "" // not installed; not an error
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, dockerTimeout)
|
||||
defer cancel()
|
||||
|
||||
idsOut, err := exec.CommandContext(ctx, "docker", "ps", "-aq").Output()
|
||||
if err != nil {
|
||||
// Installed but not answering: a different problem with a different
|
||||
// fix, so it carries a message where "not installed" does not.
|
||||
return nil, false, "docker ps failed: " + errText(err)
|
||||
}
|
||||
|
||||
ids := strings.Fields(string(idsOut))
|
||||
if len(ids) == 0 {
|
||||
return []Workload{}, true, "" // Docker present, nothing running
|
||||
}
|
||||
|
||||
args := append([]string{"inspect", "--format", "{{json .}}"}, ids...)
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).Output()
|
||||
if err != nil {
|
||||
return nil, false, "docker inspect failed: " + errText(err)
|
||||
}
|
||||
|
||||
var wls []Workload
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var di dockerInspect
|
||||
if err := json.Unmarshal([]byte(line), &di); err != nil {
|
||||
continue
|
||||
}
|
||||
wls = append(wls, dockerToWorkload(di))
|
||||
}
|
||||
return wls, true, ""
|
||||
}
|
||||
|
||||
func dockerToWorkload(di dockerInspect) Workload {
|
||||
w := Workload{
|
||||
Kind: "container",
|
||||
ID: di.ID,
|
||||
Name: strings.TrimPrefix(di.Name, "/"),
|
||||
State: di.State.Status,
|
||||
Image: di.Config.Image,
|
||||
Restarts: di.RestartCount,
|
||||
}
|
||||
if di.State.Health != nil {
|
||||
w.Health = strings.ToLower(di.State.Health.Status)
|
||||
}
|
||||
// The compose project label is what Docker itself treats as authoritative.
|
||||
// No YAML is read from disk: a compose file there may not be what is running.
|
||||
if v := di.Config.Labels["com.docker.compose.project"]; v != "" {
|
||||
w.Stack = v
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, di.State.StartedAt); err == nil {
|
||||
w.StartedAt = t
|
||||
}
|
||||
for container, bindings := range di.NetworkSettings.Ports {
|
||||
for _, b := range bindings {
|
||||
w.Ports = append(w.Ports, b.HostIP+":"+b.HostPort+"->"+container)
|
||||
}
|
||||
}
|
||||
// Map iteration order is random; sort so a stored snapshot does not reorder
|
||||
// its own ports between two otherwise identical reports.
|
||||
sort.Strings(w.Ports)
|
||||
return w
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
return strings.TrimSpace(string(ee.Stderr))
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxLogLines and MaxLogBytes are BOTH enforced, whichever binds first.
|
||||
//
|
||||
// A line count alone does not bound size: 500 lines of a container printing
|
||||
// 4KB JSON blobs is 2MB travelling over the bus. This is the same reasoning
|
||||
// that gave workflow logs a per-line cap as well as a per-run one.
|
||||
MaxLogLines = 500
|
||||
MaxLogBytes = 256 * 1024
|
||||
|
||||
logTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
// Logs returns a bounded snapshot of a workload's recent output.
|
||||
//
|
||||
// There is no follow mode. The browser console already offers a real terminal
|
||||
// on the same server where `docker logs -f` works properly, with its own
|
||||
// scrollback and cancellation. A snapshot answers "why did this restart",
|
||||
// which is the question that sends people to the console in the first place.
|
||||
func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error) {
|
||||
if tail <= 0 || tail > MaxLogLines {
|
||||
tail = MaxLogLines
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, logTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := logsPlatform(ctx, kind, id, tail)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
text, truncated := capLog(out)
|
||||
return text, truncated, nil
|
||||
}
|
||||
|
||||
// capLog enforces both limits, trimming from the FRONT: the most recent lines
|
||||
// are the ones worth keeping.
|
||||
func capLog(s string) (string, bool) {
|
||||
truncated := false
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > MaxLogLines {
|
||||
lines = lines[len(lines)-MaxLogLines:]
|
||||
truncated = true
|
||||
}
|
||||
s = strings.Join(lines, "\n")
|
||||
|
||||
if len(s) > MaxLogBytes {
|
||||
s = s[len(s)-MaxLogBytes:]
|
||||
// Drop the leading partial line left by a byte-wise cut.
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
truncated = true
|
||||
}
|
||||
|
||||
return s, truncated
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
|
||||
var cmd *exec.Cmd
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd = exec.CommandContext(ctx, "docker", "logs",
|
||||
"--tail", strconv.Itoa(tail), "--timestamps", id)
|
||||
case "unit":
|
||||
cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
|
||||
"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
|
||||
default:
|
||||
return "", fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
|
||||
// docker logs writes container stderr to our stderr, so both streams must
|
||||
// be captured or half the output silently disappears.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return "", fmt.Errorf("read logs for %s: %s", id, errText(err))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) {
|
||||
switch kind {
|
||||
case "container":
|
||||
cmd := exec.CommandContext(ctx, "docker", "logs",
|
||||
"--tail", strconv.Itoa(tail), "--timestamps", id)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return "", fmt.Errorf("read logs for %s: %s", id, errText(err))
|
||||
}
|
||||
return string(out), nil
|
||||
|
||||
case "unit":
|
||||
display := serviceDisplayName(ctx, id)
|
||||
|
||||
// Timestamps are formatted PowerShell-side rather than left to
|
||||
// ConvertTo-Json, whose DateTime rendering differs between PowerShell
|
||||
// versions — one of them emits /Date(1699...)/.
|
||||
//
|
||||
// $ErrorActionPreference = 'SilentlyContinue' because Get-WinEvent
|
||||
// treats "no events matched" as a terminating error, and a quiet
|
||||
// service is normal.
|
||||
names := psQuote(id)
|
||||
if display != "" && display != id {
|
||||
names += "," + psQuote(display)
|
||||
}
|
||||
names += "," + psQuote(scmProvider)
|
||||
|
||||
// ProviderName includes the host-wide Service Control Manager, so a
|
||||
// -MaxEvents cap of exactly tail would apply to the combined stream
|
||||
// before parseEvents narrows SCM rows down to this service — on a
|
||||
// host with busy service churn the target's own events could be
|
||||
// squeezed out of the window entirely. Over-fetch instead, hard-capped
|
||||
// so a pathological host cannot pull an unbounded batch across the
|
||||
// wire, and let parseEvents trim to the last tail lines after
|
||||
// filtering.
|
||||
fetch := tail * 5
|
||||
if fetch > 2500 {
|
||||
fetch = 2500
|
||||
}
|
||||
|
||||
script := `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$rows = Get-WinEvent -FilterHashtable @{LogName='System','Application'; ProviderName=@(` + names + `)} ` +
|
||||
`-MaxEvents ` + strconv.Itoa(fetch) + ` |
|
||||
ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
t = $_.TimeCreated.ToUniversalTime().ToString('o')
|
||||
l = [string]$_.LevelDisplayName
|
||||
p = [string]$_.ProviderName
|
||||
m = [string]$_.Message
|
||||
}
|
||||
}
|
||||
ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
out, err := winexec.Run(ctx, script)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read events for %s: %w", id, err)
|
||||
}
|
||||
return parseEvents(out, id, display, tail)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown workload kind %q", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceDisplayName resolves a service's display name, which is what Service
|
||||
// Control Manager events name it by. An empty answer is fine — the filter then
|
||||
// matches on the service name alone.
|
||||
func serviceDisplayName(ctx context.Context, id string) string {
|
||||
out, err := winexec.Run(ctx,
|
||||
"$ErrorActionPreference='SilentlyContinue'\n"+
|
||||
"(Get-Service -Name "+psQuote(id)+").DisplayName")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return trimLine(out)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
|
||||
)
|
||||
|
||||
const servicesTimeout = 60 * time.Second
|
||||
|
||||
const servicesScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$svcs = Get-CimInstance Win32_Service | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Name = $_.Name
|
||||
DisplayName = $_.DisplayName
|
||||
State = $_.State
|
||||
StartMode = $_.StartMode
|
||||
PathName = $_.PathName
|
||||
ExitCode = $_.ExitCode
|
||||
}
|
||||
}
|
||||
ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress
|
||||
`
|
||||
|
||||
// collectUnits enumerates Windows services. The bool and string it returns are
|
||||
// the same SystemdOK / SystemdError pair the Linux collector fills: the wire
|
||||
// shape is shared, and the UI words it per platform.
|
||||
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, servicesTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := winexec.Run(ctx, servicesScript)
|
||||
if err != nil {
|
||||
return nil, false, "Win32_Service query failed: " + err.Error()
|
||||
}
|
||||
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
|
||||
wls, err := parseServices(out, systemRoot)
|
||||
if err != nil {
|
||||
return nil, false, "Win32_Service output could not be read: " + err.Error()
|
||||
}
|
||||
return wls, true, ""
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const systemdTimeout = 30 * time.Second
|
||||
|
||||
// excludedPrefixes drops the platform's own units. A typical host carries 300+
|
||||
// units and systemd accounts for most of them; listing all of them buries the
|
||||
// ten anyone cares about.
|
||||
var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"}
|
||||
|
||||
// collectUnits enumerates services in two passes, because "running or
|
||||
// failed" and "enabled but stopped" are different questions — and an enabled
|
||||
// unit that is not running is exactly the one worth seeing.
|
||||
func collectUnits(ctx context.Context) ([]Workload, bool, string) {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return nil, false, ""
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, systemdTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Column output rather than --output=json: the JSON flag needs systemd
|
||||
// 246+, and this fleet includes older stable distributions. The columns
|
||||
// have been stable considerably longer than the JSON has existed.
|
||||
unitsOut, err := exec.CommandContext(ctx, "systemctl",
|
||||
"list-units", "--type=service", "--state=running,failed",
|
||||
"--no-legend", "--plain", "--no-pager").Output()
|
||||
if err != nil {
|
||||
return nil, false, "systemctl list-units failed: " + errText(err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var wls []Workload
|
||||
|
||||
for _, line := range strings.Split(string(unitsOut), "\n") {
|
||||
f := strings.Fields(line)
|
||||
// UNIT LOAD ACTIVE SUB DESCRIPTION…
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
name := f[0]
|
||||
if excluded(name) || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: name,
|
||||
Name: strings.TrimSuffix(name, ".service"),
|
||||
State: f[2], // ACTIVE: active | failed | activating | inactive
|
||||
})
|
||||
}
|
||||
|
||||
filesOut, err := exec.CommandContext(ctx, "systemctl",
|
||||
"list-unit-files", "--type=service", "--state=enabled",
|
||||
"--no-legend", "--plain", "--no-pager").Output()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(filesOut), "\n") {
|
||||
f := strings.Fields(line)
|
||||
// UNIT FILE STATE [PRESET]
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
name := f[0]
|
||||
if excluded(name) || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: name,
|
||||
Name: strings.TrimSuffix(name, ".service"),
|
||||
State: "inactive", // enabled but not currently running
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return wls, true, ""
|
||||
}
|
||||
|
||||
func excluded(name string) bool {
|
||||
for _, p := range excludedPrefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
// The build constraint is load-bearing — see updates_other.go.
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ownContainerID = ""
|
||||
|
||||
func collectUnits(context.Context) ([]Workload, bool, string) { return nil, false, "" }
|
||||
func isProtectedUnit(string, string) bool { return false }
|
||||
|
||||
func controlPlatform(context.Context, string, string, string) error {
|
||||
return fmt.Errorf("workload control is not supported on this platform")
|
||||
}
|
||||
|
||||
func logsPlatform(context.Context, string, string, int) (string, error) {
|
||||
return "", fmt.Errorf("workload logs are not supported on this platform")
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// winService is one row of Get-CimInstance Win32_Service.
|
||||
//
|
||||
// Win32_Service rather than Get-Service: Get-Service exposes neither PathName
|
||||
// nor StartMode, and the filter below needs both.
|
||||
type winService struct {
|
||||
Name string `json:"Name"`
|
||||
DisplayName string `json:"DisplayName"`
|
||||
State string `json:"State"`
|
||||
StartMode string `json:"StartMode"`
|
||||
PathName string `json:"PathName"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
}
|
||||
|
||||
// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service
|
||||
// carrying it has not failed — it has not run since boot — and painting that
|
||||
// red would cry wolf on every host.
|
||||
const exitCodeNeverStarted = 1077
|
||||
|
||||
// servicePath extracts the executable from a Win32_Service PathName.
|
||||
//
|
||||
// A naive split on whitespace misfiles a substantial share of a real fleet:
|
||||
// `"C:\Program Files\X\x.exe" -service` is one path and one argument.
|
||||
func servicePath(pathName string) string {
|
||||
s := strings.TrimSpace(pathName)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if s[0] == '"' {
|
||||
if end := strings.IndexByte(s[1:], '"'); end >= 0 {
|
||||
return s[1 : 1+end]
|
||||
}
|
||||
// No closing quote: a malformed or truncated PathName. Fall back to
|
||||
// the unquoted handling below on the text after the opening quote,
|
||||
// so this yields a bare path rather than a path plus trailing
|
||||
// argument text.
|
||||
s = s[1:]
|
||||
}
|
||||
if i := exeBoundaryIndex(s); i >= 0 {
|
||||
return s[:i+len(".exe")]
|
||||
}
|
||||
if i := strings.IndexAny(s, " \t"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// exeBoundaryIndex finds the first ".exe" (case-insensitive) in s that
|
||||
// actually ends the executable name — followed by end-of-string, whitespace,
|
||||
// or a double quote — rather than continuing into a longer segment such as
|
||||
// ".exec". It returns -1 when no such occurrence exists, so a path like
|
||||
// `C:\Program Files\Ad.exec\tool.com -flag` is not misparsed by matching the
|
||||
// ".exe" inside "Ad.exec" and silently dropping the real filename.
|
||||
func exeBoundaryIndex(s string) int {
|
||||
lower := strings.ToLower(s)
|
||||
from := 0
|
||||
for {
|
||||
rel := strings.Index(lower[from:], ".exe")
|
||||
if rel < 0 {
|
||||
return -1
|
||||
}
|
||||
idx := from + rel
|
||||
end := idx + len(".exe")
|
||||
if end == len(s) || s[end] == ' ' || s[end] == '\t' || s[end] == '"' {
|
||||
return idx
|
||||
}
|
||||
from = idx + 1
|
||||
}
|
||||
}
|
||||
|
||||
// parseServices turns the collector's JSON into workloads.
|
||||
//
|
||||
// systemRoot is a parameter rather than an environment read so this is testable
|
||||
// off Windows. The caller passes %SystemRoot%.
|
||||
//
|
||||
// The filter mirrors the systemd collector's intent: show what an operator
|
||||
// installed, and show what is meant to be up but is not. Services under
|
||||
// %SystemRoot%\System32 are the platform's own, and a typical host has well
|
||||
// over a hundred of them.
|
||||
func parseServices(jsonText, systemRoot string) ([]Workload, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rows []winService
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
var one winService
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = []winService{one}
|
||||
}
|
||||
|
||||
sys32 := strings.ToLower(strings.TrimRight(systemRoot, `\`) + `\system32\`)
|
||||
|
||||
var wls []Workload
|
||||
for _, r := range rows {
|
||||
if p := strings.ToLower(servicePath(r.PathName)); p != "" && strings.HasPrefix(p, sys32) {
|
||||
continue
|
||||
}
|
||||
|
||||
running := strings.EqualFold(r.State, "Running")
|
||||
failed := !running && r.ExitCode != 0 && r.ExitCode != exitCodeNeverStarted
|
||||
auto := strings.HasPrefix(strings.ToLower(r.StartMode), "auto")
|
||||
if !running && !failed && !auto {
|
||||
continue
|
||||
}
|
||||
|
||||
// The wire shape is shared with the systemd collector — both report
|
||||
// under kind "unit" — so the state word has to be too, or the UI
|
||||
// (which colours and filters on it, and does so before it knows
|
||||
// which platform sent the row) needs two vocabularies for one kind.
|
||||
// running/stopped/failed become active/inactive/failed to match.
|
||||
state := "inactive"
|
||||
switch {
|
||||
case running:
|
||||
state = "active"
|
||||
case failed:
|
||||
state = "failed"
|
||||
}
|
||||
|
||||
name := r.DisplayName
|
||||
if name == "" {
|
||||
name = r.Name
|
||||
}
|
||||
|
||||
wls = append(wls, Workload{
|
||||
Kind: "unit",
|
||||
ID: r.Name,
|
||||
Name: name,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
return wls, nil
|
||||
}
|
||||
|
||||
// psQuote renders a Go string as a PowerShell single-quoted literal. Single
|
||||
// quotes suppress every form of expansion, so the only character needing an
|
||||
// escape is the quote itself, which is doubled.
|
||||
func psQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// scmProvider is the provider every service's start and stop is logged under,
|
||||
// host-wide.
|
||||
const scmProvider = "Service Control Manager"
|
||||
|
||||
type winEvent struct {
|
||||
T string `json:"t"`
|
||||
L string `json:"l"`
|
||||
P string `json:"p"`
|
||||
M string `json:"m"`
|
||||
}
|
||||
|
||||
// parseEvents renders Get-WinEvent output as text in the shape journalctl
|
||||
// --output=short-iso produces, so the log dialog needs no per-platform
|
||||
// rendering: "<timestamp> <level> <message>", oldest first.
|
||||
//
|
||||
// The caller over-fetches from Get-WinEvent because the ProviderName filter
|
||||
// includes the host-wide Service Control Manager, and a -MaxEvents cap
|
||||
// applied before SCM rows are narrowed down to this service would squeeze the
|
||||
// target's own events out of the window on a host with busy service churn.
|
||||
// tail is therefore applied here, AFTER filtering and AFTER the oldest-first
|
||||
// reversal, keeping the last tail lines — the most recent lines are the ones
|
||||
// worth keeping, matching capLog's front-trim reasoning in the shared
|
||||
// logs.go.
|
||||
func parseEvents(jsonText, serviceName, displayName string, tail int) (string, error) {
|
||||
s := strings.TrimSpace(jsonText)
|
||||
if s == "" || s == "null" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var rows []winEvent
|
||||
if err := json.Unmarshal([]byte(s), &rows); err != nil {
|
||||
var one winEvent
|
||||
if err2 := json.Unmarshal([]byte(s), &one); err2 != nil {
|
||||
return "", err
|
||||
}
|
||||
rows = []winEvent{one}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, e := range rows {
|
||||
if strings.EqualFold(e.P, scmProvider) {
|
||||
if !strings.Contains(e.M, serviceName) &&
|
||||
(displayName == "" || !strings.Contains(e.M, displayName)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Collapse every newline form, not just "\r\n": a message containing a
|
||||
// bare "\n" would otherwise still break the one-line-per-event shape
|
||||
// this renders for the log dialog, and undercount the tail trim above.
|
||||
msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M))
|
||||
lines = append(lines, e.T+" "+e.L+" "+msg)
|
||||
}
|
||||
|
||||
// Get-WinEvent is newest-first. Reverse it.
|
||||
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
|
||||
lines[i], lines[j] = lines[j], lines[i]
|
||||
}
|
||||
|
||||
if tail > 0 && len(lines) > tail {
|
||||
lines = lines[len(lines)-tail:]
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// trimLine reduces single-value PowerShell output to its first non-empty line.
|
||||
func trimLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if t := strings.TrimSpace(l); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package workloads
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServicePath(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{`"C:\Program Files\Contoso\svc.exe" -service`, `C:\Program Files\Contoso\svc.exe`},
|
||||
{`C:\WINDOWS\system32\svchost.exe -k netsvcs`, `C:\WINDOWS\system32\svchost.exe`},
|
||||
{`C:\Vantage\vantage-agent.exe`, `C:\Vantage\vantage-agent.exe`},
|
||||
{`"C:\no\args.exe"`, `C:\no\args.exe`},
|
||||
{``, ``},
|
||||
// ".exe" appearing inside an earlier segment ("Ad.exec") must not be
|
||||
// treated as the end of the executable — that would drop the real
|
||||
// filename and arguments.
|
||||
{`C:\Program Files\Ad.exec\tool.com -flag`, `C:\Program`},
|
||||
// An unterminated quote falls back to the unquoted handling on the
|
||||
// text after the opening quote, yielding a bare path rather than a
|
||||
// path plus trailing argument text.
|
||||
{`"C:\Program Files\Contoso\svc.exe -service`, `C:\Program Files\Contoso\svc.exe`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := servicePath(c.in); got != c.want {
|
||||
t.Errorf("servicePath(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesFilters(t *testing.T) {
|
||||
in := `[
|
||||
{"Name":"Contoso","DisplayName":"Contoso Broker","State":"Running","StartMode":"Auto","PathName":"\"C:\\Program Files\\Contoso\\svc.exe\" -service","ExitCode":0},
|
||||
{"Name":"Themes","DisplayName":"Themes","State":"Running","StartMode":"Auto","PathName":"C:\\WINDOWS\\system32\\svchost.exe -k netsvcs","ExitCode":0},
|
||||
{"Name":"Fabrikam","DisplayName":"Fabrikam Sync","State":"Stopped","StartMode":"Auto","PathName":"C:\\Fabrikam\\sync.exe","ExitCode":0},
|
||||
{"Name":"Northwind","DisplayName":"Northwind Poller","State":"Stopped","StartMode":"Manual","PathName":"C:\\Northwind\\poll.exe","ExitCode":0},
|
||||
{"Name":"Crashed","DisplayName":"Crashed Thing","State":"Stopped","StartMode":"Auto","PathName":"C:\\Crashed\\c.exe","ExitCode":1067}
|
||||
]`
|
||||
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
|
||||
byID := map[string]Workload{}
|
||||
for _, w := range got {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// The OS's own svchost service is dropped; a manual, stopped, never-failed
|
||||
// service is nobody's business either.
|
||||
if _, ok := byID["Themes"]; ok {
|
||||
t.Error("Themes (under %SystemRoot%) should be filtered out")
|
||||
}
|
||||
if _, ok := byID["Northwind"]; ok {
|
||||
t.Error("stopped Manual service should be filtered out")
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
|
||||
}
|
||||
|
||||
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "active" {
|
||||
t.Errorf("Contoso = %+v", w)
|
||||
}
|
||||
// Enabled but not running is exactly the row worth seeing.
|
||||
if byID["Fabrikam"].State != "inactive" {
|
||||
t.Errorf("Fabrikam state = %q, want inactive", byID["Fabrikam"].State)
|
||||
}
|
||||
// A non-zero exit code on a stopped service is a crash, not a clean stop.
|
||||
if byID["Crashed"].State != "failed" {
|
||||
t.Errorf("Crashed state = %q, want failed", byID["Crashed"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// 1077 means "no attempt to start since boot" — a clean stopped service, not a
|
||||
// failure, and reporting it red would cry wolf on every host.
|
||||
func TestParseServicesExitCode1077(t *testing.T) {
|
||||
in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]`
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil {
|
||||
t.Fatalf("parseServices: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].State != "inactive" {
|
||||
t.Fatalf("got %+v, want one inactive workload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServicesSingleObjectAndEmpty(t *testing.T) {
|
||||
one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}`
|
||||
got, err := parseServices(one, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 1 || got[0].State != "active" {
|
||||
t.Fatalf("single object: got %+v, err %v", got, err)
|
||||
}
|
||||
|
||||
for _, in := range []string{"", "[]", "null"} {
|
||||
got, err := parseServices(in, `C:\WINDOWS`)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("parseServices(%q) = %+v, err %v", in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPSQuote(t *testing.T) {
|
||||
if got := psQuote(`it's`); got != `'it''s'` {
|
||||
t.Fatalf("psQuote = %s", got)
|
||||
}
|
||||
if got := psQuote(`plain`); got != `'plain'` {
|
||||
t.Fatalf("psQuote = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEventsFormatsAndOrders(t *testing.T) {
|
||||
// Get-WinEvent returns newest first; journalctl --output=short-iso returns
|
||||
// oldest first, and the log dialog and capLog's front-trim both assume the
|
||||
// most recent line is at the bottom.
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:22:31.0000000Z","l":"Error","p":"Contoso","m":"broker died"},
|
||||
{"t":"2026-08-13T10:22:03.0000000Z","l":"Information","p":"Contoso","m":"broker starting"}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
|
||||
want := "2026-08-13T10:22:03.0000000Z Information broker starting\n" +
|
||||
"2026-08-13T10:22:31.0000000Z Error broker died"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A message containing a bare "\n" (no carriage return) must still collapse to
|
||||
// one line, or it silently multiplies into several output lines and throws
|
||||
// off the tail trim's count.
|
||||
func TestParseEventsCollapsesBareLF(t *testing.T) {
|
||||
in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
if strings.Count(got, "\n") != 0 {
|
||||
t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got)
|
||||
}
|
||||
want := "2026-08-13T10:00:00Z Error broker died stack trace here"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Service Control Manager logs every service on the host under one provider, so
|
||||
// its rows must be filtered down to the target or the log is somebody else's.
|
||||
func TestParseEventsFiltersOtherServicesSCM(t *testing.T) {
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:00:00Z","l":"Information","p":"Service Control Manager","m":"The Print Spooler service entered the running state."},
|
||||
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Service Control Manager","m":"The Contoso Broker service entered the running state."}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
if strings.Contains(got, "Print Spooler") {
|
||||
t.Errorf("another service's SCM event leaked in:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Contoso Broker") {
|
||||
t.Errorf("the target's SCM event was dropped:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A service that has logged nothing is normal. An error there would read as a
|
||||
// broken feature.
|
||||
func TestParseEventsEmpty(t *testing.T) {
|
||||
for _, in := range []string{"", "[]", "null"} {
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("parseEvents(%q) = %q, err %v", in, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The over-fetch in logs_windows.go can return more events than the caller
|
||||
// asked for once SCM rows are filtered down to the target; parseEvents must
|
||||
// keep the most RECENT tail lines, not the oldest, matching capLog's
|
||||
// front-trim reasoning in the shared logs.go.
|
||||
func TestParseEventsTrimsToTailKeepingMostRecent(t *testing.T) {
|
||||
in := `[
|
||||
{"t":"2026-08-13T10:00:06Z","l":"Information","p":"Contoso","m":"event 6"},
|
||||
{"t":"2026-08-13T10:00:05Z","l":"Information","p":"Contoso","m":"event 5"},
|
||||
{"t":"2026-08-13T10:00:04Z","l":"Information","p":"Contoso","m":"event 4"},
|
||||
{"t":"2026-08-13T10:00:03Z","l":"Information","p":"Contoso","m":"event 3"},
|
||||
{"t":"2026-08-13T10:00:02Z","l":"Information","p":"Contoso","m":"event 2"},
|
||||
{"t":"2026-08-13T10:00:01Z","l":"Information","p":"Contoso","m":"event 1"}
|
||||
]`
|
||||
|
||||
got, err := parseEvents(in, "Contoso", "Contoso Broker", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("parseEvents: %v", err)
|
||||
}
|
||||
|
||||
want := "2026-08-13T10:00:05Z Information event 5\n" +
|
||||
"2026-08-13T10:00:06Z Information event 6"
|
||||
if got != want {
|
||||
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user