Compare commits

...
17 Commits
Author SHA1 Message Date
mrhid6 487de34a50 docs: Updated docs
Server Deploy / deploy (push) Failing after 1m57s
2026-07-28 16:53:42 +01:00
mrhid6 0424547dd4 docs: Updated docs 2026-07-28 16:53:38 +01:00
mrhid6 5be9ddb2e5 fix: Fixed baked api url
Server Deploy / deploy (push) Successful in 7m8s
2026-07-28 16:18:50 +01:00
mrhid6 bc6c7cdb5a feat: Updated docker file
Server Deploy / deploy (push) Successful in 2m35s
2026-07-28 16:04:51 +01:00
mrhid6 f46fb7fc0e feat: documentation site
Docusaurus 3 docs-only site at docsite/, served statically by nginx under
/docs on the marketing host. Covers getting started (self-hosted install
through first server and first licence), the control plane, Vantage HQ,
a reference section and operations.

Wired into docker-compose.site.yml as docsite (3005:80) and into the
image build workflow, rebuilding on its own directory only. Never added
to the self-hosted compose file.
2026-07-28 15:46:33 +01:00
mrhid6 d9945882e5 docs: serve the documentation site under /docs on the marketing host 2026-07-28 15:22:20 +01:00
mrhid6 9db16522e3 docs: design for the documentation site 2026-07-28 15:20:07 +01:00
mrhid6 6070972f9a chore: updated deps
Server Deploy / deploy (push) Successful in 36s
2026-07-28 15:07:19 +01:00
mrhid6 31e0000306 feat: default steps are read only
Server Deploy / deploy (push) Successful in 1m46s
2026-07-28 13:34:08 +01:00
mrhid6 26567766a9 feat: Move default steps into container 2026-07-28 13:28:43 +01:00
mrhid6 97dc6feaca feat: Tapo platform page
Server Deploy / deploy (push) Successful in 1m30s
2026-07-28 12:57:49 +01:00
mrhid6 f10e61cde4 feat: Updated marketing site 2026-07-28 12:46:28 +01:00
mrhid6 febe48a974 feat: Updated pricing 2026-07-28 12:31:00 +01:00
mrhid6 766dcabfde fix: Fixes to the console focus
Server Deploy / deploy (push) Successful in 43s
2026-07-28 12:02:48 +01:00
mrhid6 c9dab99271 feat: Windows agent inventory
Server Deploy / deploy (push) Successful in 9s
Agent Release / build (push) Successful in 1m39s
Agent Release / msi (push) Successful in 36s
2026-07-28 11:29:08 +01:00
mrhid6 60a4ed9aab fix: Fixed agent msi update command
Server Deploy / deploy (push) Successful in 16s
Agent Release / build (push) Successful in 10m40s
Agent Release / msi (push) Successful in 38s
2026-07-28 10:53:06 +01:00
mrhid6 f56fc2e54d fix: Fixed agent build
Server Deploy / deploy (push) Successful in 4m8s
Agent Release / build (push) Successful in 10m44s
Agent Release / msi (push) Successful in 1m43s
2026-07-28 10:30:29 +01:00
119 changed files with 25293 additions and 1357 deletions
+16 -4
View File
@@ -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: |
@@ -106,7 +108,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/
@@ -152,3 +153,14 @@ jobs:
-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"
+10 -35
View File
@@ -20,19 +20,14 @@ 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;
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,18 +46,10 @@ 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>
@@ -70,10 +57,7 @@ export default function BillingPage() {
}
>
{rows.length === 0 ? (
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
You have no subscriptions. Cloud instances and self-hosted licences are
both bought from the pricing page.
</p>
<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">
@@ -88,21 +72,12 @@ export default function BillingPage() {
</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>
<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>
<td className="px-4 py-3 font-mono tabular-nums">{formatDate(s.current_period_end)}</td>
</tr>
))}
</tbody>
@@ -11,7 +11,7 @@ export default function LinkPage() {
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.
// is nothing to do here send them there.
const claimId = useSearchParams().get("claim") ?? undefined;
useEffect(() => {
+3 -7
View File
@@ -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 }) {
+18 -64
View File
@@ -35,14 +35,12 @@ 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.
// only account-level view of it each record only knows about itself.
const attention = live.flatMap((i) => {
const lic = byInstance.get(i.instance_id);
const state = licenceState(lic?.expires_at, Boolean(lic));
if (state === "none")
return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }];
if (state === "expired")
return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }];
if (state === "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 [
{
@@ -57,40 +55,26 @@ export default function OverviewPage() {
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.
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>
@@ -104,16 +88,9 @@ export default function OverviewPage() {
<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"
>
<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>
)}
{a.note && <span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">{a.note}</span>}
</li>
))}
</ul>
@@ -123,27 +100,18 @@ export default function OverviewPage() {
<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 +119,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,23 +133,14 @@ 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>
@@ -199,12 +158,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)}
/>
);
})}
@@ -88,7 +88,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]);
@@ -321,8 +321,7 @@ export function PurchaseForm() {
<Block n={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.
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 +331,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&rsquo;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&rsquo;s Settings Licence page, or the setup screen just after first sign-in.</span>
</label>
</div>
</Block>
@@ -423,7 +419,7 @@ export function PurchaseForm() {
)}
{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">Your instance is being set up. 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 +550,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>
);
})}
@@ -25,9 +25,7 @@ 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 }] : []),
]}
/>
@@ -35,10 +33,7 @@ export default function AccountDetailPage() {
<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"
>
<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">
@@ -71,19 +66,10 @@ export default function AccountDetailPage() {
{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>
<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>
)}
{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>
@@ -94,9 +80,7 @@ export default function AccountDetailPage() {
<span>
{e.action} · {e.actor}
</span>
<span className="tabular-nums text-ink-3">
{formatDate(e.created_at)}
</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>}
@@ -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>
@@ -14,7 +14,7 @@ import { IssuePanel } from "./IssuePanel";
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" },
@@ -44,30 +44,19 @@ 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">
@@ -76,10 +65,7 @@ export default function StaffInstancePage() {
<IssuePanel instanceId={data.instance.instance_id} />
</section>
<EntitlementSection
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
</div>
);
}
@@ -114,8 +100,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 +112,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 +138,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>
);
}
+8 -27
View File
@@ -35,9 +35,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 +44,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 +62,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 +109,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,10 +117,7 @@ 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>
@@ -139,14 +127,9 @@ export default function StaffDashboard() {
<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>
<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"
>
<Link href="/staff/audit" className="text-[0.82rem] font-semibold text-accent underline">
Full audit
</Link>
</header>
@@ -163,9 +146,7 @@ export default function StaffDashboard() {
<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 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>
+15 -60
View File
@@ -26,15 +26,7 @@ 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);
@@ -43,9 +35,7 @@ function AllowanceForm({
<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]}
@@ -60,20 +50,14 @@ function AllowanceForm({
}
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
/>
<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 })
}
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"
>
{SUPPORT_LEVELS.map((s) => (
@@ -86,25 +70,13 @@ 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"
>
<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">
{saving ? "Saving…" : "Save allowances"}
</button>
</div>
@@ -131,26 +103,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);
@@ -161,29 +127,18 @@ 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>
<h2 className="text-[0.95rem] font-medium text-ink">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
{(plans.data ?? [])
.filter((p) => p.deployment === deployment)
.map((p) => (
<article
key={`${p.deployment}/${p.tier}`}
className="rounded-lg border border-rule bg-panel p-4"
>
<article key={`${p.deployment}/${p.tier}`} className="rounded-lg border border-rule bg-panel p-4">
<header className="mb-3 flex items-baseline justify-between gap-3">
<h3 className="text-[0.9rem] font-medium text-ink">
{p.name}
</h3>
<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)}
/>
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
</article>
))}
</section>
+5 -19
View File
@@ -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}
+3 -17
View File
@@ -12,9 +12,7 @@ export function buttonClass(variant: Variant = "solid", disabled = false, classN
return clsx(
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
"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 +26,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}>
+4 -25
View File
@@ -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 -1
View File
@@ -21,7 +21,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
+7 -30
View File
@@ -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>
+7 -24
View File
@@ -4,19 +4,11 @@ import { useState } from "react";
import { Button } from "./Button";
/*
* A licence blob is signed public data, not a secret it is useless on any
* A licence blob is signed public data, not a secret it is useless on any
* instance other than the one it names. So it is safe to show inline, and
* showing it is what stops a blocked download from blocking a paying customer.
*/
export function LicenceDelivery({
instanceId,
blob,
downloadUrl,
}: {
instanceId: string;
blob: string;
downloadUrl: string;
}) {
export function LicenceDelivery({ instanceId, blob, downloadUrl }: { instanceId: string; blob: string; downloadUrl: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
@@ -27,13 +19,11 @@ export function LicenceDelivery({
const steps = [
<>
Open <code className="rounded-sm bg-accent-wash px-1">Settings Licence</code> on your
install.
Open <code className="rounded-sm bg-accent-wash px-1">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 className="rounded-sm bg-accent-wash px-1">Valid</code> straight away no restart.
</>,
];
@@ -52,18 +42,11 @@ export function LicenceDelivery({
{copied ? "Copied" : "Copy to clipboard"}
</Button>
</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>
<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>
<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>
<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>
))}
+13 -39
View File
@@ -9,7 +9,7 @@ import { Button } from "@/components/Button";
const ROLES: InstanceRole[] = ["owner", "admin", "member"];
/*
* Absent entirely for self-hosted instances the backend refuses those, and a
* 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.
*/
export function MembersPanel({ instanceId }: { instanceId: string }) {
@@ -26,8 +26,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,8 +38,7 @@ 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,
});
@@ -54,29 +52,21 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
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 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>
<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>
</div>
{error && <p className="text-[0.9rem] text-expired">{error}</p>}
<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"
>
<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 ? (
@@ -104,8 +94,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
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);
if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
}}
>
Remove
@@ -114,9 +103,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
</span>
</li>
))}
{members.data?.length === 0 && (
<li className="text-ink-2">Nobody has been added yet.</li>
)}
{members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
</ul>
{canManage && (
@@ -129,14 +116,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
}}
>
<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"
>
<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}>
@@ -146,14 +127,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
</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"
>
<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}
@@ -169,8 +144,7 @@ export function MembersPanel({ instanceId }: { instanceId: string }) {
{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.
{pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
</p>
)}
</section>
+5 -8
View File
@@ -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&rsquo;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&rsquo;s{" "}
<code>ADMIN_ORIGIN</code>, or the browser blocks every request.
</p>
</div>
);
+6 -26
View File
@@ -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>
+4 -11
View File
@@ -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 };
@@ -60,10 +60,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">
&larr; {back.label}
</Link>
)}
@@ -80,12 +77,8 @@ export function PageHeader({
<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>
<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>
{f.copy && <CopyButton value={f.value} />}
</span>
))}
+5 -34
View File
@@ -6,18 +6,7 @@ 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;
}) {
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);
@@ -26,32 +15,14 @@ export function RelinkPanel({
return (
<section className="grid gap-3 border-t border-rule-soft pt-5">
<h2 className="text-xl">Moved to a new server?</h2>
<p className="text-[0.82rem] text-ink-2">
Relinking issues a replacement licence for the new install, covering the rest of
your current term.
</p>
{open && !exhausted && (
<Field
label="New instance ID"
value={value}
onChange={(e) => setValue(e.target.value)}
error={error}
hint="From Settings → Licence on the new install."
/>
)}
<p className="text-[0.82rem] text-ink-2">Relinking issues a replacement licence for the new install, covering the rest of your current term.</p>
{open && !exhausted && <Field label="New instance ID" value={value} onChange={(e) => setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="line"
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
>
<Button type="button" variant="line" disabled={exhausted || (open && !UUID_RE.test(value.trim()))} onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}>
Relink to a new install
</Button>
<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`}
{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>
</div>
</section>
+2 -4
View File
@@ -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
View File
@@ -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
+6 -3
View File
@@ -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
+4 -3
View File
@@ -1,12 +1,13 @@
//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.
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
func collect(r *pb.InventoryReport, includeStatic bool) {}
+183
View File
@@ -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
}
+45 -3
View File
@@ -412,13 +412,55 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
}
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
if err := up.Start(); err != nil {
// The MSI stops the vantage-agent service as part of the upgrade. Anything
// descended from this process is killed with it, so msiexec must not be a
// child: run it from a scheduled task, which is parented to the Task
// Scheduler service instead.
if err := launchDetachedUpdate(msiPath, logPath, cmd.CommandId); err != nil {
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
return
}
log.Printf("scheduled msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
}
const updateTaskName = "VantageAgentUpdate"
func launchDetachedUpdate(msiPath, logPath, commandID string) error {
scriptPath := filepath.Join(os.TempDir(), "vantage-agent-update.cmd")
script := fmt.Sprintf("@echo off\r\n"+
"timeout /t 5 /nobreak >nul\r\n"+
"msiexec /i \"%s\" /qn /norestart /l*v \"%s\"\r\n"+
"schtasks /delete /tn %s /f >nul 2>&1\r\n"+
"del /f /q \"%s\" >nul 2>&1\r\n"+
"(goto) 2>nul & del /f /q \"%%~f0\"\r\n",
msiPath, logPath, updateTaskName, msiPath)
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
return fmt.Errorf("write update script: %w", err)
}
// Stale task from a previous attempt would make /create fail even with /f
// if it is still running, so tear it down first and ignore the result.
exec.Command("schtasks", "/end", "/tn", updateTaskName).Run()
exec.Command("schtasks", "/delete", "/tn", updateTaskName, "/f").Run()
create := exec.Command("schtasks", "/create",
"/tn", updateTaskName,
"/tr", `"`+scriptPath+`"`,
"/sc", "once",
// Already in the past: the task never fires on its own, only via /run.
"/st", "00:00",
"/ru", "SYSTEM",
"/rl", "HIGHEST",
"/f")
if out, err := create.CombinedOutput(); err != nil {
return fmt.Errorf("schtasks create: %v: %s", err, strings.TrimSpace(string(out)))
}
if out, err := exec.Command("schtasks", "/run", "/tn", updateTaskName).CombinedOutput(); err != nil {
return fmt.Errorf("schtasks run: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func downloadFile(url, dest string) error {
+80 -63
View File
@@ -94,6 +94,11 @@ vantage/
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
│ └── lib/ # api client, session guards, formatters
├── docsite/ # user documentation (Docusaurus, static)
│ ├── docs/ # getting-started, vantage, hq, reference, operations
│ ├── src/css/custom.css # site/'s tokens, copied, mapped onto --ifm-*
│ ├── sidebars.ts # authored by hand, not autogenerated
│ └── nginx.conf # serves the build under /docs
├── shared/ # imported by server, sitesvc and admin
│ ├── mail/ # the one email system: transport + tmpl templates
│ ├── license/ # payload, sign, verify, trusted keys, plans
@@ -117,7 +122,7 @@ Upload a public key, assign it per server, revoke softly. The agent diffs desire
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
Default steps are seeded per org at boot (`SeedDefaultSteps`). Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
Default steps are seeded per org at boot (`SeedDefaultSteps`) from `VANTAGE_DEFAULT_STEPS_DIR`, which `server/Dockerfile` bakes to `/opt/default-steps` from the repo's `default_steps/`. Deliberately **not** under `/data` — that is a bind mount, so the library would be editable from the host. Adding a step there means committing a file and rebuilding, which is why `default_steps/` is in the `server` rebuild trigger. **Steps with `source: "default"` are read-only**: `UpdateStep`/`DeleteStep` refuse with `ErrDefaultStep` (409), because seeding rewrites them on every boot, so an edit would silently revert and a delete would come back. `web/` mirrors this — the step modal opens read-only, Delete is hidden, and the designer's per-step script override is `readOnly` for a default library step — but as elsewhere, the API is the boundary and the UI is the courtesy. Seeding writes straight to the collection rather than through `UpdateStep`, so the guard does not lock out the seeder. Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
### Monitors
@@ -147,19 +152,25 @@ Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full stat
`site/` is a separate Next.js app built exactly like `web/``output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3003`. The contact form posts to `sitesvc`; account signup posts to `admin` (`NEXT_PUBLIC_ADMIN_API_URL`), which creates an HQ account, not an org — the control plane is not touched until the customer later creates a cloud instance from the portal.
`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately *outside* `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play.
`adminsite/` is built the same way and published as `3004`, served at **`vantage-hq.hostxtra.co.uk`** — deliberately _outside_ `*.vantage.hostxtra.co.uk`, because that namespace is per-tenant instance subdomains and `APP_ROOT_LABEL` resolves an org from the label before `vantage`. It shares `site/`'s design tokens verbatim (see Frontend below) and, unlike `web/`, does **not** proxy through a Next rewrite: the browser calls `admin` directly, so `ADMIN_API_URL` must be browser-reachable. Authenticated requests work cross-origin only because both hosts share the registrable domain `hostxtra.co.uk`, which keeps `admin_session`'s `SameSite=Lax` cookie in play.
**`ADMIN_ORIGIN` must list every browser origin that calls admin — currently two**: `https://vantage-hq.hostxtra.co.uk` for the console, and `https://vantage.hostxtra.co.uk` because the marketing site's `/start` form posts account signups to admin directly. It is comma-separated. A missing origin does not produce a 403: `cors()` simply omits the `Access-Control-Allow-Origin` header and still answers the preflight `204`, so the browser blocks the request and **admin logs nothing at all**. Symptom is a CORS preflight failure on an endpoint that works fine under curl.
`sitesvc/` (port `8082`) now owns only the contact flow:
| Form | Endpoint | Effect |
| ------- | --------------------- | ----------------------------------------------------------------------- |
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
| Form | Endpoint | Effect |
| ------- | ------------------- | ----------------------------------------------------------------------- |
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
Account signup lives in `admin` instead (`POST /auth/signup`, `GET /auth/verify?token=…`) — see Signup and verification below.
`site`, `sitesvc` and `admin` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
`site`, `sitesvc`, `admin` and `docsite` are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
### Documentation site
`docsite/` is the user-facing documentation — Docusaurus 3 in docs-only mode (`routeBasePath: "/"`, no blog), one version tracking `main`, search indexed at build time by `@easyops-cn/docusaurus-search-local` so nothing external is keyed or called. It documents the **product**, not the codebase: this file remains the contributor's map, and the two are allowed to differ in altitude but not in fact. Five sections — Getting started, Vantage, Vantage HQ, Reference, Operations — with `sidebars.ts` authored by hand so ordering is a decision rather than a filename accident.
Unlike the three Next apps it builds to static files, so its runtime stage is `nginx:alpine-slim` rather than Node, and it listens on `80`. See the compose note below for the `/docs` prefix, which is the one thing about it that is easy to get wrong.
```bash
# self-hosted install — no marketing site, no sitesvc
@@ -234,7 +245,7 @@ instance authenticates it exactly as it authenticates anyone else, with **no
runtime dependency on admin**. Revoking deletes that row — the control plane has
no disabled state, and a row that exists is a row that can sign in.
`instance_members` in admin's database is only admin's *index* of those
`instance_members` in admin's database is only admin's _index_ of those
projections; the control-plane row is the access. That is why a failed
`instance_members` insert unwinds the projection, and why the boot backfill can
rebuild the index from the control plane but never the other way round.
@@ -266,7 +277,7 @@ password-change endpoint at all, so there is no competing writer for the hash.
- **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
Unique indexes are a **security property**, not an optimisation. `users` is
unique on `(instance_id, email)` — one address is one user *within* an instance,
unique on `(instance_id, email)` — one address is one user _within_ an instance,
and the same address may hold a user in several instances, because an account's
people are projected into each instance they are granted. This is sufficient only
because **every lookup by email is scoped by instance**; there is deliberately no
@@ -406,7 +417,7 @@ GET /health/injection · /health/billing
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's *current* line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired``granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired``granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
## MongoDB Collections
@@ -424,7 +435,7 @@ Notes that are not obvious from the structs:
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
- `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
@@ -498,32 +509,33 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
## Environment Variables (server)
| Name | Required | Notes |
| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
| `MONGO_DB` | no | default `vantage` |
| `REDIS_ADDR` | no | default `localhost:6379` |
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
| `GITEA_HOST` | yes | used to build install scripts and agent download URLs |
| `GUACD_ADDR` | no | default `guacd:4822` |
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
| Name | Required | Notes |
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
| `MONGO_DB` | no | default `vantage` |
| `REDIS_ADDR` | no | default `localhost:6379` |
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
| `GUACD_ADDR` | no | default `guacd:4822` |
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
| `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
**sitesvc** (`deploy/docker-compose.site.yml` only):
| Name | Required | Notes |
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Name | Required | Notes |
| --------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MONGO_URI` | yes | **must point at the control plane's database.** sitesvc no longer provisions orgs itself, but it still refuses to start (`RequireMigratedDatabase`) against a database that has not run migration `0004` (the `orgs` → `instances` rename), and it (re)declares the shared `users.email` / `instances.slug` indexes at boot. The database name is read from the URI path; a URI without one is refused rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
| `SMTP_HOST` / `SMTP_FROM` | yes | without them the contact form refuses (503) rather than silently dropping |
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds four more — `site` (3003), `sitesvc` (8082), `admin` (8083) and `adminsite` (3004) — and is only used on vantage.hostxtra.co.uk.
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds five more — `site` (3003), `sitesvc` (8082), `admin` (8083), `adminsite` (3004) and `docsite` (3005) — and is only used on vantage.hostxtra.co.uk.
`docsite` is the odd one: a **static** build served by `nginx:alpine-slim`, not a Node runtime, and it listens on `80` rather than `3000`. It is reached at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing host, routed by its own Nginx Proxy Manager location, which must sort **above** the catch-all forwarding to `site:3003` or Next answers the 404. A path and not a subdomain because `*.vantage.hostxtra.co.uk` is the per-tenant instance namespace and `APP_ROOT_LABEL` would read a `docs.` label as a tenant slug. NPM forwards the **full** path upstream — it does not strip `/docs` — so `DOCS_BASE_URL`, the proxy location and the directory the image copies the build into (`/usr/share/nginx/html/docs`) must all agree. When they do not, the HTML loads and every asset 404s.
`LICENSE_SIGNING_KEY` appears in **exactly one service in exactly one compose file**: `admin` in `docker-compose.site.yml`. It must never be added to `server`, and the self-hosted `docker-compose.yml` must never mention `admin` or `adminsite` at all. Admin uses an external Redis via `REDIS_ADDR`/`REDIS_USERNAME`/`REDIS_PASSWORD`; the base compose hardcodes `redis:6379` for `server`, so those variables reach admin only.
@@ -547,17 +559,20 @@ Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent a
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
All three apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
All four apps are **one visual system**, anchored on the logo navy. What differs between them is which end of it they stand on:
| App | Ground | Accent | Themes |
| --- | --- | --- | --- |
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| App | Ground | Accent | Themes |
| ------------ | -------------------------- | -------------------------------- | --------------------------- |
| `web/` | `--ground` dark, `#071628` | `#5b9be8` | dark only, locked |
| `site/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark |
| `adminsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
| `docsite/` | token-based | `#0b2a58` light / `#5b9be8` dark | light + dark, light default |
`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all three files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
`adminsite/app/globals.css` and `docsite/src/css/custom.css` hold `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all four files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
There is a **fourth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the three web apps carries a hex.
`docsite/` is the one place the tokens are not consumed through Tailwind: everything below its token block maps Docusaurus's `--ifm-*` variables onto them. Docusaurus already stamps `data-theme` on `<html>`, which is the selector `site/`'s dark block keys on, so the built-in toggle needed no wiring. The rule holds all the same — no rule in that file outside the token blocks carries a hex. The one concession is `docsite/static/img/favicon.svg`, which must, for the same reason the email layout must: a browser tab cannot read a token.
There is a **fifth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the four web apps carries a hex.
Tailwind in all three maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
@@ -614,7 +629,7 @@ GOOS=linux GOARCH=amd64 go build \
### `server-deploy.yml` — triggered on every push to `main`
Builds and pushes six images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin` and `adminsite`.
Builds and pushes seven images to the Gitea container registry: `server`, `web`, `site`, `sitesvc`, `admin`, `adminsite` and `docsite`.
Note that despite the name, **this workflow does not deploy** — it only builds and pushes. There is no SSH step. Rolling images out is a separate manual step on the host:
@@ -625,16 +640,16 @@ cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.y
**Each image only rebuilds when its own inputs changed.** A `git diff` against `github.event.before` decides, which is why the checkout uses `fetch-depth: 0` — the default shallow clone has one commit and nothing to diff — and why `git` is installed in the `docker:dind` container. The mapping follows the build contexts exactly:
| Image | Rebuilds when |
| --- | --- |
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
| `admin` | `admin/`, `shared/`, `go.work` |
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
| `web` · `site` · `adminsite` | their own directory only |
| Image | Rebuilds when |
| ---------------------------- | ----------------------------------------- |
| `server` | `server/`, `shared/`, `proto/`, `go.work` |
| `admin` | `admin/`, `shared/`, `go.work` |
| `sitesvc` | `sitesvc/`, `shared/`, `go.work` |
| `web` · `site` · `adminsite` · `docsite` | their own directory only |
`shared/` fans out to all three Go images because each of their Dockerfiles copies `shared/` from a root context — **if a fourth service ever imports `shared/`, add it to that list or it will ship stale**. A change to the workflow file rebuilds everything, since a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone.
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `API_URL`, `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that.
### Tagging
@@ -645,23 +660,25 @@ git push origin main # server + web deploy
### Secrets / variables
| Name | Type | Value |
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
| `REGISTRY_USER` | Secret | Gitea username |
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| `API_URL` | Variable | baked into the `web` image at build time |
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
| `PADDLE_CLIENT_TOKEN`| Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
| Name | Type | Value |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
| `REGISTRY_USER` | Secret | Gitea username |
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| `API_URL` | **not** a CI variable | `web` reads it at **runtime**, from the container environment — `next.config.ts` is evaluated when `server.js` boots in standalone mode, and the rewrites it feeds are server-side, never browser-side. Default `http://localhost:8080`; compose sets `http://server:8080`. `NEXT_PUBLIC_API_URL` is still honoured as a fallback for existing deployments. |
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
| `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. |
| `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. |
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
| `PADDLE_CLIENT_TOKEN` | Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. |
| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. |
| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. |
| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. |
| `DOCS_URL` | Variable | site `url` baked into `docsite`; `https://vantage.hostxtra.co.uk`. Empty falls back to that default rather than breaking the build. |
| `DOCS_BASE_URL` | Variable | `/docs/`. Must match the NPM location and the directory the image serves from — all three, or the HTML loads and every asset 404s. |
| `APP_URL` | Variable | control-plane link in `docsite`'s navbar. |
---
+12
View File
@@ -0,0 +1,12 @@
{
"kind": "vantage.step/v1",
"name": "Get Host Name",
"description": "",
"interpreter": "bash",
"script": "HOSTNAME=$(hostname)\necho $HOSTNAME\necho \"HOSTNAME=$HOSTNAME\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": [
"HOSTNAME"
],
"declared_inputs": [],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "List Directory",
"description": "",
"interpreter": "bash",
"script": "if [ ! -e $path ]; then\n echo \"file or directory doesn't exist: $path\"\n exit 1\nfi\nls -l $path",
"declared_outputs": [],
"declared_inputs": [
{
"name": "path",
"default": "./",
"description": ""
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Start Linux Service",
"description": "",
"interpreter": "bash",
"script": "echo \"starting service $serviceName\"\nsystemctl start $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to start service\"\n exit 1\nfi",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": ""
}
],
"secret_refs": []
}
@@ -0,0 +1,16 @@
{
"kind": "vantage.step/v1",
"name": "Stop Linux Service",
"description": "",
"interpreter": "bash",
"script": "echo \"stopping service $serviceName\"\nsystemctl stop $serviceName\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to stop service\"\n exit 1\nfi",
"declared_outputs": [],
"declared_inputs": [
{
"name": "serviceName",
"default": "",
"description": ""
}
],
"secret_refs": []
}
+18
View File
@@ -0,0 +1,18 @@
{
"kind": "vantage.step/v1",
"name": "WGET",
"description": "",
"interpreter": "bash",
"script": "out=$(mktemp -p ./)\necho \"Downloading file from $url\"\nwget -q $url -O $out\nretVal=$?\nif [ $retVal -ne 0 ]; then\n echo \"failed to download file from url\"\n exit 1\nfi\necho \"FILE_PATH=$out\" \u003e\u003e $WORKFLOW_ENV",
"declared_outputs": [
"FILE_PATH"
],
"declared_inputs": [
{
"name": "url",
"default": "",
"description": ""
}
],
"secret_refs": []
}
-3
View File
@@ -7,9 +7,6 @@
# Must be reachable from managed servers. Use the public host, port 9090.
GRPC_HOST=192.168.1.250:9090
# Gitea host used to build agent install scripts and download URLs.
GITEA_HOST=gitea.hostxtra.co.uk
# 64-char hex (32 bytes) for AES-256-GCM. Required for private keys,
# secrets, OIDC secrets, RDP/VNC credentials.
# Generate: openssl rand -hex 32
+9
View File
@@ -55,4 +55,13 @@ services:
- 3004:3000
depends_on:
- admin
# Static docs, served by nginx at vantage.hostxtra.co.uk/docs through its own
# proxy location. That location must sort ABOVE the catch-all forwarding to
# site:3003, or Next serves its own 404 for /docs. The container serves from
# /usr/share/nginx/html/docs because the proxy forwards the full path.
docsite:
image: gitea.hostxtra.co.uk/mrhid6/vantage/docsite:latest
restart: unless-stopped
ports:
- 3005:80
networks: {}
+2 -1
View File
@@ -41,7 +41,6 @@ services:
environment:
MONGO_URI: ${MONGO_URI:-mongodb://mongo:27017/vantage}
REDIS_ADDR: redis:6379
GITEA_HOST: ${GITEA_HOST}
GRPC_HOST: ${GRPC_HOST}
GRPC_PORT: "9090"
HTTP_PORT: "8080"
@@ -61,6 +60,8 @@ services:
restart: unless-stopped
ports:
- 3000:3000
environment:
API_URL: ${API_URL:-http://server:8080}
depends_on:
- server
volumes:
@@ -1830,7 +1830,6 @@ sleep 6
MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
-e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \
-e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \
-e GITEA_HOST=example.invalid \
--add-host host.docker.internal:host-gateway vantage-server:p2
sleep 6
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
@@ -2012,7 +2011,6 @@ MSYS_NO_PATHCONV=1 docker rm -f p2-server
MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
-e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \
-e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \
-e GITEA_HOST=example.invalid -e FREE_INSTANCE_REAP_AFTER=336h \
--add-host host.docker.internal:host-gateway vantage-server:p2
sleep 6
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
@@ -780,7 +780,6 @@ Expected: output includes `email_1`. This reproduces a database that predates th
MSYS_NO_PATHCONV=1 docker run -d --name vantage-idx-server -p 8091:8080 \
-e MONGO_URI=mongodb://host.docker.internal:27023 -e MONGO_DB=vantage_idx \
-e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6389 \
-e GITEA_HOST=example.invalid \
--add-host host.docker.internal:host-gateway vantage-server:test
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
@@ -0,0 +1,162 @@
# Documentation site — design
**Date:** 2026-07-28
**Status:** approved
## Problem
Vantage has no user-facing documentation. Everything an operator needs — how to
install self-hosted, how to enrol an agent, what a workflow step is, how a
licence gets issued — lives either in `CLAUDE.md` (written for contributors, not
users) or in the code. The marketing site sells the product and the control
plane runs it; neither explains it.
## Solution
A fourth Next-adjacent frontend, `docsite/`, built with Docusaurus v3 and shipped
alongside the marketing site.
### Placement and deployment
- Lives at repo root as `docsite/`.
- Served at **`vantage.hostxtra.co.uk/docs`** — a path on the marketing site's
host, routed by a separate Nginx Proxy Manager custom location rather than by
`site/`. It is a path, not a subdomain, deliberately: `*.vantage.hostxtra.co.uk`
is the per-tenant instance namespace and `APP_ROOT_LABEL` resolves an org from
the label before `vantage`, so a `docs.` label there would be read as a tenant
slug.
This makes `baseUrl: "/docs/"` load-bearing. An NPM custom location forwards
the **full** request path upstream — it does not strip the `/docs` prefix — so
the container serves the build from `/usr/share/nginx/html/docs`, not from the
document root. Prefix, asset URLs and upstream paths then agree with no
rewrite rule to keep in step. Getting this wrong is quiet: the HTML loads and
every stylesheet and script 404s.
- Added to **`deploy/docker-compose.site.yml` only**, published as `3005`.
`deploy/docker-compose.yml` (the self-hosted install) must never mention it,
exactly as it never mentions `site`, `sitesvc`, `admin` or `adminsite`.
- Added to `.gitea/workflows/server-deploy.yml` as a seventh image, rebuilding
on `^docsite/` only, build context `docsite/`.
### Runtime
Docusaurus emits a fully static site, so unlike `site/`, `web/` and
`adminsite/` there is no Node server at runtime. Two stages:
1. `node:26-alpine` builder — `npm ci && npm run build``/app/build`.
2. `nginx:alpine-slim` runner — copies `build/` to
`/usr/share/nginx/html/docs` (see the `/docs` prefix note above), plus a
small `nginx.conf` giving `try_files` a 404 fallback to Docusaurus's
`404.html` and long cache headers on `/docs/assets/`. A bare `/` request
redirects to `/docs/`, so hitting the container directly is not a blank 403.
`nginx:alpine-slim` is roughly 12MB against `caddy:alpine`'s ~50MB, and nothing
here needs Caddy's automatic TLS — the host proxy already terminates it.
Build args, baked at build time the same way `site/`'s are:
| Arg | Purpose |
| --- | --- |
| `DOCS_URL` | site `url`; defaults to `https://vantage.hostxtra.co.uk` |
| `DOCS_BASE_URL` | site `baseUrl`; defaults to `/docs/`. Must match the NPM location and the runner's copy target |
| `APP_URL` | navbar link to the control plane |
| `HQ_URL` | navbar link to the HQ portal |
### Theme
`docsite/src/css/custom.css` carries `site/app/globals.css`'s token blocks
**copied verbatim** — same names, same values — and maps Docusaurus's `--ifm-*`
variables onto them. Docusaurus stamps `data-theme="light|dark"` on `<html>`,
which is the same selector `site/`'s dark block already keys on, so the built-in
toggle works with no extra wiring. Light is the default, matching `site/` and
`adminsite/`.
This makes a **fifth** copy of the token block (`site/`, `adminsite/`, `web/`
dark-only, `shared/mail/templates/layout.html.tmpl` as literal hex, and now
`docsite/`). Nothing enforces the match; `CLAUDE.md`'s Frontend section is
updated to say so. No component in `docsite/` may carry a hex value.
Search is `@easyops-cn/docusaurus-search-local` — index built at compile time,
served from the same origin. No Algolia account, no external host, nothing to
key or rotate.
Docs-only mode: `routeBasePath: "/"`, blog disabled, no tutorial scaffolding.
## Content
Sidebar is authored explicitly in `sidebars.ts` rather than autogenerated, so
ordering is a decision rather than a filename accident.
### Getting Started
| Page | Covers |
| --- | --- |
| `what-is-vantage` | The control plane, the agent, what problem each solves |
| `cloud-vs-self-hosted` | The two deployments, what differs (licensing, HQ-managed users, reaping) |
| `self-hosted-install` | Prereqs (Docker, external MongoDB, DNS, TLS), `docker-compose.yml`, required env, `docker compose up -d` |
| `first-login` | `/setup` bootstrap, first org and owner |
| `first-server` | `POST /servers/new`, the install one-liner, Linux and Windows, watching it flip to `active` |
| `claim-free-licence` | Linking the install to an HQ account, `claim-free` |
`self-hosted-install` is the page the section exists for; it names every
required environment variable with its consequence-of-omission, in particular
`GRPC_HOST` (boot fails, no default is safe) and `KEY_ENCRYPTION_KEY`.
### Vantage (the application)
`servers` (agent install Linux/Windows, inventory, OS updates, agent
self-update) · `ssh-keys` (upload, generate-on-server, assign, revoke, what the
agent writes and when) · `workflows` (step library, default steps and why they
are read-only, the designer, running, live logs, `on_failure`, `output_env`,
workspaces, log retention) · `monitors` (the four check types, server vs agent
runner, retries, incidents, uptime rollups) · `notification-channels` (five
types, testing) · `secrets` (vault, `secret_refs` in steps, the ESO read path
and its bearer token) · `browser-console` (SSH/RDP/VNC, one-time tokens) ·
`audit-log` · `settings` (members and roles, OIDC per org, retention, ESO token,
licence).
### Vantage HQ (the portal)
`accounts-and-signup` (account-first signup, email verification, an account is
a team) · `people-and-roles` (owner/admin/member, invitations, accepting) ·
`cloud-instances` (create, overview, granting members and what a grant actually
is) · `self-hosted-instances` (purchase creates a placeholder, claim-link binds
the real UUID, relink) · `licensing-and-entitlements` (tiers, metered server
count, feature toggles, desired vs granted) · `billing` (Paddle as merchant of
record, checkout, the customer portal, changing configuration) · `free-tier`
(limits, the renewal window, reaping on cloud).
### Reference
`environment-variables` (server, sitesvc, admin, agent) · `rest-api` (the route
tables, grouped as in `CLAUDE.md`) · `grpc-api` (the eight RPCs, the command
stream) · `agent-config` (config.yaml, paths, permissions) ·
`ports-and-networking` (which ports, which direction, what needs to be
reachable) · `troubleshooting`.
### Operations
`upgrading` (pull and recreate) · `backups` (MongoDB is the durable state; Redis
is sessions only) · `agent-updates` · `ci-cd` (which image rebuilds when, and
the repo-variable gap that pushes no commit).
## Writing rules
- Every guide is task-shaped: numbered steps, real paths and commands taken from
the repository, never invented UI.
- Behaviour that is a hard refusal gets an admonition, not a paragraph: default
steps are read-only (409 `ErrDefaultStep`), `POST /license` answers 409
`cloud_managed` on cloud, HQ-sourced users cannot have their role changed
locally (409 `ErrHQManaged`).
- Where the UI enforces something, say that the API is the boundary and the UI
is the courtesy — the same phrasing the codebase uses.
- No screenshots in this pass. They rot faster than prose and there is no
capture pipeline.
## Out of scope
- Versioned documentation. One version, tracking `main`. Docusaurus versioning
can be switched on later without restructuring.
- Internationalisation.
- Screenshots and diagrams beyond what Mermaid renders inline.
- A docs search backed by an external service.
+5
View File
@@ -0,0 +1,5 @@
node_modules
build
.docusaurus
.git
.gitignore
+4
View File
@@ -0,0 +1,4 @@
node_modules
build
.docusaurus
.cache-loader
+40
View File
@@ -0,0 +1,40 @@
# Build stage
FROM node:26-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
# Baked in at build time. DOCS_BASE_URL must agree with three things at once:
# the Nginx Proxy Manager location that routes to this container, the directory
# the runtime stage serves from below, and this value. When they disagree the
# HTML loads and every stylesheet and script 404s.
ARG DOCS_URL="https://vantage.hostxtra.co.uk"
ARG DOCS_BASE_URL="/docs/"
ARG APP_URL="https://vantage.hostxtra.co.uk"
ARG HQ_URL="https://vantage-hq.hostxtra.co.uk"
ENV DOCS_URL=$DOCS_URL
ENV DOCS_BASE_URL=$DOCS_BASE_URL
ENV APP_URL=$APP_URL
ENV HQ_URL=$HQ_URL
RUN npm run build
# Runtime stage
#
# Docusaurus emits a fully static site, so unlike web/, site/ and adminsite/
# there is no Node server at runtime. alpine-slim is roughly a quarter the size
# of caddy:alpine, and nothing here needs automatic TLS — the host proxy
# terminates it.
FROM nginx:alpine-slim AS runner
# NPM forwards the FULL request path upstream; it does not strip the /docs
# prefix. Serving from a matching subdirectory means prefix, asset URLs and
# upstream paths agree with no rewrite rule to keep in step.
COPY --from=builder /app/build /usr/share/nginx/html/docs
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
@@ -0,0 +1,80 @@
---
id: claim-free-licence
title: Claim a Free licence
sidebar_label: Claim a Free licence
---
A self-hosted install runs unlicensed until you give it a licence. Free is a
real tier in both deployments, and you can claim one for your install from the
HQ portal.
## What a licence is
A signed file. It carries the instance UUID it belongs to, the tier, the server
allowance, feature toggles and an expiry. The control plane verifies the
signature locally checking a licence never contacts HQ, and a running instance
does not need HQ to be reachable.
Signing happens in exactly one place, in HQ. The control plane can only verify.
## 1. Find your instance UUID
In the control plane, go to **Settings → Licence**. The instance UUID is shown
there. It is the identity your licence binds to.
## 2. Link the install to your HQ account
1. Sign in at [Vantage HQ](https://vantage-hq.hostxtra.co.uk). If you have no
account, see [Accounts and signup](../hq/accounts-and-signup.md).
2. Choose **Link an instance**.
3. Paste the instance UUID and give it a name you will recognise.
Linking claims the UUID for your account. A UUID already linked elsewhere is
refused with a conflict rather than silently moved.
## 3. Claim Free
With the instance linked, choose **Claim Free** on it. HQ issues a Free licence
bound to that UUID and hands it back.
:::info One Free per account, per deployment
The limit is enforced per account **and** deployment, so a Free cloud instance
does not stop you claiming Free on a self-hosted install. Both the friendly
pre-check and the issuer apply the same rule deliberately, because a
pre-check stricter than the issuer would refuse something that would actually
have worked.
:::
## 4. Install the licence
Download the licence from HQ and paste it in the control plane at
**Settings → Licence**.
The instance validates the signature, checks the UUID matches its own, and
starts reporting the tier, allowance and expiry.
:::warning Cloud instances cannot paste a licence
On a cloud instance `POST /license` answers `409 cloud_managed`, and the UI
hides the form entirely. A cloud licence is written directly by HQ. This is not
a restriction the injection path has to work around it writes to the database,
not through the endpoint.
:::
## Renewing
Free licences are renewable from HQ within a renewal window near expiry;
outside that window the renew call refuses. See [Free tier](../hq/free-tier.md).
Pasting a licence keeps working while the current one is expired that endpoint
is exempt from the licence check, because it is the way out of degraded mode.
## Moving the install to new hardware
Rebuilding produces a new instance UUID, and a licence binds to a UUID. Use
**Relink** in HQ to move the licence across. The number of relinks per term is
capped; the portal shows how many you have left.
## Next
- [Licensing and entitlements](../hq/licensing-and-entitlements.md)
- [Buying a paid self-hosted licence](../hq/self-hosted-instances.md)
@@ -0,0 +1,67 @@
---
id: cloud-vs-self-hosted
title: Cloud or self-hosted
sidebar_label: Cloud or self-hosted
---
Vantage runs in two deployments. They are the same software; what differs is
who operates it and how licensing, users and data lifecycle work.
## At a glance
| | Cloud | Self-hosted |
| --------------------- | --------------------------------------------------- | ------------------------------ |
| Who runs it | We do | You do |
| Where you sign in | `<your-slug>.vantage.hostxtra.co.uk` | Your own hostname |
| Database and backups | Ours | Yours |
| Licence | Written for you when you buy or create the instance | Pasted in, or claimed from HQ |
| Team members | Granted from HQ; the instance holds a projection | Created in the instance itself |
| Free tier | Yes, one per account | Yes, one per account |
| Expired Free instance | Eventually deleted, after warning | Never deleted |
## Cloud
You create an instance from the HQ portal and it exists a few seconds later,
already licensed. People you grant access to get a real user inside that
instance see [People and roles](../hq/people-and-roles.md) but HQ owns their
password, role and existence.
:::info The instance does not phone home
A grant writes a user row into the control plane once. After that the instance
authenticates that person entirely on its own. HQ being down does not stop
anyone signing in to a running instance.
:::
Cloud instances on the Free tier are reaped after their licence expires, with
warning emails first. See [Free tier](../hq/free-tier.md).
## Self-hosted
You run the Docker Compose stack on your own infrastructure. Nothing about the
control plane requires an internet connection to HQ at runtime a licence is a
signed file, verified locally.
Two ways to get one:
1. **Free** link the install to an HQ account and claim it
([Claim a Free licence](./claim-free-licence.md)).
2. **Paid** buy from HQ, which creates a placeholder, then paste the install's
real instance UUID to bind and issue
([Self-hosted instances](../hq/self-hosted-instances.md)).
Self-hosted users are local (or OIDC). There is no projection from HQ, and the
three member endpoints in HQ refuse to touch a self-hosted instance at all.
## Which should you pick
Pick cloud if you want the thing running now and do not want to own a MongoDB.
Pick self-hosted if your policy requires the control plane inside your own
network, or the servers you manage cannot reach the public internet.
Moving between them is a migration, not a switch instances are bound to a
deployment at creation, and a licence binds to an instance UUID.
## Next
- [Self-hosted install](./self-hosted-install.md)
- [Accounts and signup](../hq/accounts-and-signup.md) if you are going cloud
@@ -0,0 +1,75 @@
---
id: first-login
title: First login
sidebar_label: First login
---
A fresh install has no users and no organisation. The first visit creates both.
## 1. Bootstrap
Open the control plane in a browser. Because no user exists, you land on
`/setup`.
Fill in:
| Field | Notes |
| ----------------- | ----------------------------------------------------------------- |
| Organisation name | Display name. Shown throughout the UI |
| Slug | Lowercase, used in the hostname on cloud. Some names are reserved |
| Your name | |
| Email | Becomes your sign-in identity |
| Password | Stored bcrypt-hashed |
Submitting creates the organisation and its **owner** you.
:::warning Bootstrap works exactly once
The endpoint is open only while the database has no users. As soon as the first
one exists, `/setup` redirects to the login page and the bootstrap endpoint
refuses. There is no second chance to create the first owner, so record the
credentials before you close the tab.
:::
## 2. Sign in
You are taken to `/login`. Sign in with the email and password you just set.
Sessions are an opaque 32-byte token in the `km_session` cookie, with the body
held in Redis for 24 hours. Restarting Redis signs everyone out and loses
nothing else.
## 3. Look around
You land on the fleet dashboard, which is empty. The sidebar is the whole
product:
| Section | What it does |
| --------- | ----------------------------------------- |
| Servers | The fleet enrol, inspect, console, update |
| Keys | SSH public keys and their assignments |
| Workflows | Compose and run scripted work |
| Steps | The reusable step library |
| Monitors | HTTP, TCP, ICMP and TLS checks |
| Secrets | The encrypted vault |
| Audit | Every mutating action |
| Settings | Members, SSO, alerts, retention, licence |
## 4. Add the rest of your team
Go to **Settings → Access**. Add members with a role:
| Role | Can |
| -------- | -------------------------------------------------- |
| `owner` | Everything, including billing-adjacent settings |
| `admin` | Everything except owner-only settings |
| `member` | Day-to-day work servers, keys, workflows, monitors |
Settings and organisation management require `owner` or `admin`.
If you would rather not manage passwords, configure OIDC instead see
[Settings](../vantage/settings.md#single-sign-on-oidc). OIDC is configured per
organisation, and the client secret is stored encrypted.
## Next
[Add your first server](./first-server.md).
@@ -0,0 +1,111 @@
---
id: first-server
title: Add your first server
sidebar_label: Add your first server
---
Enrolling a machine means running one command on it. The control plane issues a
short-lived token, the install script fetches the agent and writes a config, and
the machine registers itself.
## 1. Create the enrolment
In the UI, go to **Servers → Add server**. That calls `POST /api/servers/new`,
which generates a server ID and a pre-registration token and hands back a ready
one-liner.
:::warning The token is single-use and lives one hour
It is the only credential in the flow, and it is spent the moment the agent
calls `Register`. If you paste it somewhere and come back tomorrow, create a new
enrolment instead nothing is lost by doing so.
:::
## 2. Run the one-liner
### Linux
```bash
curl -fsSL "https://vantage.example.com/install?server_id=<id>&token=<token>" | bash
```
Run it as root. The script:
1. Detects architecture `x86_64` and `aarch64` only; anything else exits.
2. Asks the Gitea API for the newest `agent/v*` release.
3. Downloads the binary and `checksums.txt`, and **verifies the SHA-256**,
aborting on a mismatch.
4. Installs to `/usr/local/bin/vantage-agent`, mode `0755`.
5. Writes `/etc/vantage/config.yaml` (directory `0700`, file `0600`) containing
the server ID, the pre-registration token and the gRPC host.
6. Writes `/etc/systemd/system/vantage-agent.service` with `Restart=always`, and
runs `systemctl enable --now vantage-agent`.
### Windows
```powershell
irm "https://vantage.example.com/install.ps1?server_id=<id>&token=<token>" | iex
```
Run from an elevated PowerShell. The agent is registered as a service through
NSSM, with the config at `%ProgramData%\vantage\config.yaml`. There is also an
MSI built by CI if you would rather deploy that.
:::info Windows agents are second-class on purpose
They register, heartbeat, run workflow steps and report inventory. They do
**not** manage `authorized_keys` the key subsystem is Linux-only, and a
Windows agent stops after the heartbeat portion of the poll.
:::
## 3. Watch it come up
The server appears immediately as `pending`. Within one poll interval 30
seconds it flips to `active`.
On the machine:
```bash
systemctl status vantage-agent
journalctl -u vantage-agent -f
```
What happens on that first run:
```
1. Load /etc/vantage/config.yaml
2. pre_reg_token present → register → save agent_token, clear pre_reg_token
3. Reconnect with the permanent token
4. Start: command stream · hourly update check · inventory · monitors
5. Enter the key poll loop
```
After registration the config no longer contains the pre-registration token; it
contains a permanent agent token instead. The control plane stores only the
SHA-256 of that token, never the token itself.
## 4. Confirm it works
Open the server's detail page. Within a minute or two you should see:
- Status `active`, with a recent last-seen timestamp.
- Inventory CPU, memory, swap, partitions, kernel. Metrics refresh every 30
seconds; the full static snapshot every 15 minutes.
- Pending OS updates, checked hourly.
## If it does not appear
| Symptom | Cause |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| Script exits at "Unsupported architecture" | Not amd64 or arm64 |
| "Checksum mismatch!" | Interrupted download, or a proxy rewriting the body. Re-run |
| "Could not determine latest agent version" | The host cannot reach `gitea.hostxtra.co.uk`, or no `agent/v*` release exists |
| Service runs, server stays `pending` | The machine cannot reach `GRPC_HOST`. Test it from that machine |
| Registers once then goes `offline` | Reachable for `Register` but not for the poll usually a firewall that permits the initial connection but drops the long-lived one |
A server is marked `offline` when its last-seen time passes the threshold; that
sweep runs every two minutes, so allow for it before concluding anything.
## Next
- [Assign an SSH key](../vantage/ssh-keys.md)
- [Run a workflow](../vantage/workflows.md)
- [Claim a Free licence](./claim-free-licence.md)
@@ -0,0 +1,136 @@
---
id: self-hosted-install
title: Install Vantage (self-hosted)
sidebar_label: Self-hosted install
---
This installs the control plane on a host you own. Budget about fifteen minutes.
## Before you start
You need:
- A Linux host with **Docker** and the **Compose plugin**.
- A DNS name pointing at it. You will use it for both the web UI and, with a
port, for agents.
- A reverse proxy terminating TLS in front of the web UI. gRPC on `:9090` is
reached directly by agents.
- Two ports reachable from every machine you intend to manage: the web port for
people, and **9090** for agents.
- Outbound access from the control plane, and from every managed machine, to
`gitea.hostxtra.co.uk`, which serves the agent releases.
The stack itself brings MongoDB, Redis and guacd with it. You do not need to
provide a database.
## 1. Get the compose file
Put `deploy/docker-compose.yml` from the repository in a working directory, for
example `/opt/vantage`.
```bash
mkdir -p /opt/vantage/data && cd /opt/vantage
# copy docker-compose.yml here
```
The `server` service bind-mounts `./data`, which is where workflow run logs are
written. Create it before first boot so it is not owned by root-in-container in
a way you did not intend.
## 2. Write the environment file
Create `/opt/vantage/.env`:
```bash
# The host:port agents dial. NOT the web URL this port speaks gRPC.
GRPC_HOST=vantage.example.com:9090
# 32 bytes as 64 hex characters. Generate with the command below.
KEY_ENCRYPTION_KEY=
# Optional: where workflow run logs are written inside the container.
VANTAGE_WORKFLOW_LOG_DIR=/data/workflow-logs
```
Generate the encryption key:
```bash
openssl rand -hex 32
```
:::danger Keep the encryption key
`KEY_ENCRYPTION_KEY` encrypts SSH private keys, vault secrets, OIDC client
secrets and console credentials with AES-256-GCM. Lose it and every one of those
becomes unreadable there is no recovery path. Back it up somewhere other than
the server it protects, and never rotate it without a planned re-encryption.
:::
:::warning `GRPC_HOST` has no default
The server refuses to boot without it. There is deliberately no fallback to the
web host: that would hand every agent a port that does not speak gRPC, and the
failure would only surface later, on each agent, as a connection error.
:::
## 3. Start the stack
```bash
docker compose up -d
docker compose ps
```
Five services come up: `mongo`, `redis`, `guacd`, `server` and `web`.
Check the server got through boot:
```bash
docker compose logs -f server
```
Boot runs database migrations, builds indexes and seeds the default workflow
step library. Index builders for auth and settings are **fatal on failure**
they enforce tenant isolation, so the server would rather not start than start
without them.
## 4. Put a proxy in front
Point your reverse proxy at `web` on port `3000` and terminate TLS there. The
web app calls the REST API through a Next rewrite, so you do not need to expose
`8080` publicly.
Do **not** proxy `9090`. Agents connect to it directly over TLS.
## 5. First sign-in
Open your hostname in a browser. With no users in the database, you are sent to
`/setup`.
Continue with [First login](./first-login.md).
## Verifying the install
| Check | Expected |
| ---------------------------------------------- | ------------------------------- |
| `docker compose ps` | five services `running` |
| `curl -s localhost:8080/auth/bootstrap-status` | JSON saying bootstrap is needed |
| `nc -z your-host 9090` | open |
| `docker compose logs server \| grep -i fatal` | nothing |
## Common install problems
**Server exits immediately.** Almost always a missing `GRPC_HOST`. The log line
names it.
**Agents register but never go active.** They reached `:9090` for `Register` but
cannot sustain the poll, or `GRPC_HOST` names a host they resolve differently.
Check from the managed machine, not from the control plane host.
**Secrets pages error.** `KEY_ENCRYPTION_KEY` is empty or not 64 hex characters.
More in [Troubleshooting](../reference/troubleshooting.md).
## What this install does not include
The website, the HQ portal and this documentation site are hosted by us and are
not part of a self-hosted install. It deliberately runs none of them, and in
particular never holds the licence signing key.
@@ -0,0 +1,63 @@
---
id: what-is-vantage
title: What is Vantage
sidebar_label: What is Vantage
---
Vantage manages a fleet of servers from one place. It began as SSH key
management and grew outwards: key assignment, scripted workflow execution,
service monitoring, a secrets vault, a browser-based console and OS update
management.
## The pieces
```mermaid
flowchart TD
W["Web UI<br/>servers · keys · workflows · monitors<br/>secrets · audit · console · settings"]
S["Server<br/>REST :8080 · gRPC :9090<br/>MongoDB · Redis · guacd"]
A["Agent<br/>one per managed server<br/>Linux and Windows"]
W -->|REST, cookie session| S
S -->|gRPC over TLS| A
A -.->|outbound only| S
```
**The server** holds all state and does all decision-making. It exposes a REST
API on `:8080` for the web UI and a gRPC API on `:9090` for agents. MongoDB
stores everything durable; Redis stores sessions and nothing else.
**The agent** is a single Go binary running as root on each managed server. It
polls the control plane every 30 seconds for desired key state, and holds a
bidirectional command stream so the server can push work run a workflow step,
generate a key, apply updates without waiting for the next poll.
**The web UI** is the operator interface. Everything it does goes through the
REST API, which is the actual security boundary; the UI only ever makes things
convenient.
## How agents connect
The agent dials **out** to the control plane. There is no inbound listener on a
managed server, no port to open and no NAT traversal to arrange. If the machine
can reach your Vantage host on the gRPC port, it can be managed.
That direction is why `GRPC_HOST` exists as an explicit setting: the agent has
to be told a `host:port` it can reach, and there is no safe default the server
could guess on its behalf.
## Two request patterns
| Pattern | Used for | Why |
| ----------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Poll, every 30s | desired SSH key state | Key changes are not urgent, and polling survives a dropped connection with no reconnection logic |
| Push, over the command stream | workflow steps, key generation, updates, agent self-update | Clicking Run should not wait up to 30 seconds |
## Multi-tenancy
Every document in the database carries an instance ID, and every query is scoped
by it. One deployment can therefore host many independent tenants. On a
self-hosted install that mechanism is still there you simply have one tenant.
## Next
- [Cloud or self-hosted](./cloud-vs-self-hosted.md) which one you want
- [Self-hosted install](./self-hosted-install.md) stand it up
+78
View File
@@ -0,0 +1,78 @@
---
id: accounts-and-signup
title: Accounts and signup
sidebar_label: Accounts and signup
---
Vantage HQ, at `vantage-hq.hostxtra.co.uk`, is where you manage the **account**
behind your instances: your team, your instances, their licences and billing.
## An account is a team, not a person
One account holds many people and many instances. Everyone in it has an account
role:
| Role | Can |
| -------- | ------------------------------------------------------ |
| `owner` | Everything, including billing |
| `admin` | Invite people, create instances, grant instance access |
| `member` | Read what the account holds |
Reading is open to any signed-in member. Every mutation except changing your own
password requires `owner` or `admin`. Billing is owner-only.
The three words are the same as the control plane's roles, on purpose but they
are separate things. Your account role governs the portal; your role _inside_ an
instance governs that instance.
## Signing up
Signup is **account-first**. Creating an account creates the account and you;
it does not create a Vantage instance. Nothing exists in any control plane until
you later create or link one.
1. Go to the signup form.
2. Enter your name, email and a password.
3. Check your email and click the verification link.
:::info Verify before you can sign in
An unverified account gets a distinct "check your email" message rather than a
generic authentication failure the address is already known to be yours, so
there is nothing to protect by being vague.
:::
Verification links are valid for **24 hours**. The token is 32 random bytes and
only its SHA-256 hash is stored, so a leaked database yields no working links.
If the verification email cannot be sent, the signup is rolled back rather than
left stranded retry rather than assuming a half-created account is in the way.
## Signing in
Email and password. The session is a cookie, separate from the control plane's:
signing in to HQ does not sign you in to an instance, and vice versa.
## What comes next
| You want | Go to |
| ------------------------------ | ------------------------------------------------------------- |
| A Vantage instance we run | [Cloud instances](./cloud-instances.md) |
| To license an install you run | [Self-hosted instances](./self-hosted-instances.md) |
| To add colleagues | [People and roles](./people-and-roles.md) |
| To understand tiers and limits | [Licensing and entitlements](./licensing-and-entitlements.md) |
## The portal layout
Three destinations: **Overview**, **People**, **Billing**.
Settings lives in the account menu rather than the nav, because it is your
password rather than a place. The appearance toggle is there too.
Overview lists your instances. Each is one record, closed to a row and open to
its licence contents, members and actions. It opens by default when it is your
only instance or when it needs attention, and your manual choice is remembered.
There is deliberately no "your plan" card in the sidebar: tier, limits and
expiry belong to a **licence**, and a licence belongs to one instance. An
account with a Free cloud instance and a Professional self-hosted one has no
single plan to show.
+83
View File
@@ -0,0 +1,83 @@
---
id: billing
title: Billing
sidebar_label: Billing
---
Paid plans are billed through **Paddle**, which is the merchant of record. Your
invoice, your card details and your tax handling are all Paddle's; HQ holds a
customer reference and nothing sensitive.
Billing is **owner-only**.
## Buying
### Cloud
Open the instance, change its configuration to what you want, and check out.
Checkout runs in the browser.
### Self-hosted
**Buy self-hosted**, then bind the purchase to your install's UUID. See
[Self-hosted instances](./self-hosted-instances.md).
## What you are buying
A subscription's line items are the configuration: the plan base, the metered
server count above the base, and any per-instance features. Changing the
configuration changes the line items.
## Changing configuration
**Instance → Configuration**, adjust servers or features, and save.
- **Increases** take effect when the payment confirms.
- **Reductions** are scheduled for the end of the term. The portal shows the
date and the new value.
## The customer portal
**Billing → Manage** mints a Paddle customer-portal session where you can
update your payment method, see invoices and cancel.
## How a licence follows a payment
```mermaid
flowchart LR
C["Checkout / change"] --> P["Paddle"]
P -->|signed webhook| H["HQ"]
H --> G["Entitlement: desired → granted"]
G --> L["Licence signed from granted"]
```
The webhook is the **only** issuing path for paid plans. It is signature
verified, processed exactly once, and resolved from the subscription's _current_
line items so a webhook that arrives out of order still produces the right
answer rather than replaying a stale state.
A licence is signed from **granted** only. A checkout you abandon changes
nothing.
## Cancelling and failed payments
Cancelling, or a payment going past due, takes **no immediate licence action**.
Your licence runs to its grace-padded expiry and then lapses normally. There is
no mid-term cut-off.
For a cloud Free instance, lapsing eventually leads to deletion see
[Free tier](./free-tier.md). Paid instances are not reaped.
## Renewals
At renewal the subscription bills again and the licence is reissued for the new
term. It is also the only moment a scheduled **reduction** takes effect.
Self-hosted customers: download and paste the reissued licence. Cloud customers:
nothing to do.
## Free is not in Paddle at all
Free has no subscription, no £0 line item and no Paddle record. It has its own
renewal, in the portal. An account only acquires a Paddle customer reference
with its first paid purchase.
+78
View File
@@ -0,0 +1,78 @@
---
id: cloud-instances
title: Cloud instances
sidebar_label: Cloud instances
---
A cloud instance is a Vantage control plane we run for you, reachable at
`<your-slug>.vantage.hostxtra.co.uk`.
## Creating one
1. **Overview → New instance**.
2. Choose a name and a slug.
3. Create.
The instance is provisioned with you as its owner, and a Free licence is issued
immediately. The owner user inside it gets your HQ password hash **copied**, not
shared see [People and roles](./people-and-roles.md).
### Slugs
The slug becomes your hostname label, so it is lowercase, and some names are
reserved. Pick something you can say on a phone call.
:::warning One Free instance per account, per deployment
Creating a second Free cloud instance is refused. If you want another, it needs
a paid plan or free up the first.
:::
## Using it
Sign in at your instance's hostname with the email and password you use for HQ.
It is a normal Vantage install from that point: see
[Getting started](../getting-started/first-login.md) and the
[Vantage](../vantage/servers.md) section.
The instance does not depend on HQ at runtime. HQ being unreachable does not
affect anyone signing in or any agent syncing.
## The instance record
Each instance on Overview is one record. Closed, it is a row. Open, it shows:
- **Licence contents** tier, server allowance, features, expiry.
- **Members** who has access and with what instance role.
- **Actions** grant access, change configuration, renew.
## Members
Granting access writes a real user into the instance. Covered fully in
[People and roles](./people-and-roles.md).
## Changing what it can do
Server allowance and per-instance features (browser console, single sign-on) are
part of the instance's **entitlement**. Changing it goes through billing see
[Licensing and entitlements](./licensing-and-entitlements.md) and
[Billing](./billing.md).
## Renaming
The display name is free to change. The slug is the hostname and is not
casually changed ask support if you need it.
## What happens if the licence lapses
A cloud instance whose Free licence expires enters degraded mode, then, after a
grace period, the instance and all its data are deleted. Warning emails go out
first. See [Free tier](./free-tier.md).
Paid instances do not get reaped by that mechanism. A cancelled subscription
runs to its grace-padded expiry and then lapses.
## Deleting
Ask support. Deletion is performed by the control plane, not by HQ the control
plane is the only service that knows which collections carry the instance ID,
and duplicating that list into HQ would be a list that drifts.
+78
View File
@@ -0,0 +1,78 @@
---
id: free-tier
title: Free tier
sidebar_label: Free tier
---
Free is a real tier in both deployments not a trial that turns into nothing.
## What you get
| | Free |
| --------------------- | --------- |
| Servers | 3 |
| Monitors | 3 |
| Secret groups | 1 |
| Notification channels | 1 |
| Audit retention | 30 days |
| Support | Community |
Browser console and single sign-on are not included; they are per-instance
features on a paid plan.
## One per account, per deployment
The limit is enforced per account **and** deployment. A Free cloud instance does
not prevent a Free self-hosted one they are separate slots.
## Free is outside Paddle
There is no subscription, no £0 line item and no invoice. Your account acquires
a Paddle customer reference only with its first paid purchase.
## Renewing
Free licences have a term and must be renewed from the portal.
- The renew button appears **7 days before expiry**.
- It stays available **after** expiry, right up until the instance is reaped —
so the same button rescues a lapsed instance rather than needing a second
mechanism.
- Renewing outside that window is refused, and the message names the date it
opens.
:::tip Put the expiry in a calendar
Warning emails go to the account address. If nobody watches that inbox, a Free
cloud instance can lapse and eventually be deleted without anyone noticing.
:::
## What happens when it lapses
**Self-hosted:** the instance goes into degraded mode after the grace period and
stays that way. Nothing is deleted, ever.
**Cloud:** the instance goes into degraded mode, and after a further period the
instance **and all its data are deleted**. Warning emails are sent first, naming
the date.
:::danger Deletion is permanent
There is no restore. If a cloud Free instance is approaching that date and you
want to keep it, renew it, or move it to a paid plan.
:::
Deletion is carried out by the control plane rather than by HQ. HQ sends the
warnings because it knows the billing address; the control plane performs the
delete because it is the only service that knows which collections carry the
instance ID.
## Moving off Free
Change the instance's configuration to a paid tier and check out. Your data
stays where it is a tier change reissues a licence, it does not rebuild
anything.
## Relinks
Free instances get the same relink allowance as paid ones: three per term. That
cap exists to put a human in front of a fourth attempt, not to obstruct a
genuine rebuild.
@@ -0,0 +1,95 @@
---
id: licensing-and-entitlements
title: Licensing and entitlements
sidebar_label: Licensing and entitlements
---
A **licence** is a signed statement of what one instance may do. An
**entitlement** is the configuration a licence is cut from.
## Tiers
Three tiers, in both deployments. The allowances are identical across cloud and
self-hosted what differs is the term on offer, not what you get.
| | Free | Professional | Enterprise |
| --------------------- | --------- | ------------ | --------------------- |
| Servers (base) | 3 | 3 | 10 |
| Monitors | 3 | unlimited | unlimited |
| Secret groups | 1 | unlimited | unlimited |
| Notification channels | 1 | unlimited | unlimited |
| Audit retention | 30 days | 365 days | unlimited |
| Support | Community | Email, 24×5 | Email and phone, 24×7 |
The server count is **metered**: the base allowance comes with the tier, and you
buy additional servers on top. That is why Professional shows a real number
rather than "unlimited" the number you actually have is the one in your
entitlement.
## Features
Two are per-instance toggles rather than tier bundles:
| Feature | What it enables |
| --------- | ------------------------------------------------------------------------- |
| `console` | The [browser console](../vantage/browser-console.md) |
| `oidc` | Per-instance [single sign-on](../vantage/settings.md#single-sign-on-oidc) |
No tier includes them by default; you enable them on the instances that need
them.
## Entitlements: desired and granted
Each instance has one entitlement row holding two configurations:
| | Meaning |
| ----------- | ---------------------------- |
| **Desired** | What you last asked for |
| **Granted** | What a payment has confirmed |
Checkout is built from **desired**. A licence is only ever signed from
**granted**. An abandoned checkout therefore leaves a desired that reached
nothing and changed nothing.
### Increases and reductions
An increase takes effect when payment confirms, and the entitlement is promoted
desired → granted.
A **reduction** is scheduled rather than immediate: you keep what you paid for
until the end of the term, and the portal shows the date it drops. The collapse
happens at renewal.
## What a licence carries
Instance UUID, deployment, tier, resolved limits, features, term and expiry —
all signed.
Two properties follow from that:
- **A licence is bound to one instance UUID.** Moving it takes a
[relink](./self-hosted-instances.md#relinking).
- **A licence is a snapshot.** Editing a plan later never rewrites an issued
licence, the same way editing a workflow step never rewrites a past run.
Verification is local. Your instance does not call HQ to check a licence, and
signing happens only in HQ.
## Expiry and grace
Expiry is padded with a grace period. Past that, the instance goes into degraded
mode: it keeps running and keeps your data, but stops letting you do everything.
The way out is a current licence renew or purchase, then paste it (self-hosted)
or let it be written for you (cloud).
## Server limits in practice
When you exceed your server allowance, enrolling another one is refused. The
existing fleet is unaffected. Raise the allowance in the portal, or remove a
server you are not using.
## Legacy tiers
An older `self_hosted` tier is mapped forward to self-hosted Professional
wherever it appears. Nothing needs doing about it.
+98
View File
@@ -0,0 +1,98 @@
---
id: people-and-roles
title: People and roles
sidebar_label: People and roles
---
Two separate things live here: who is in your **account**, and who has access to
each **instance**.
## Account members
**People** lists everyone in the account.
| Role | Can |
| -------- | ----------------------------------------------- |
| `owner` | Everything, including billing |
| `admin` | Invite, create instances, grant instance access |
| `member` | Read |
Owners and admins invite; billing is owner-only.
### Inviting someone
1. **People → Invite**.
2. Enter their email and pick a role.
3. They receive a link and set their own password at `/accept-invite`.
:::info Why you cannot set their password
An invitation creates a person with an **empty password hash**, which cannot
authenticate at all until they set one. If the inviter chose it, that password
would be a shared credential to every instance the person is later granted
access to.
The verification endpoint knows the difference: a token belonging to a
passwordless person reports that a password is needed and is left unspent, so
the link still works when they get to it.
:::
### Removing someone
Removing them from the account removes their portal access. See below for what
happens to their instance access.
## Instance access
Granting access to a **cloud** instance creates a real user inside that
instance's control plane, with `auth_source: "hq"`.
```mermaid
flowchart LR
P["HQ account member"] -->|grant| U["Control-plane user<br/>auth_source: hq"]
U --> I["The instance authenticates<br/>this user like any other"]
```
The instance authenticates that user exactly as it authenticates anyone else,
with **no runtime dependency on HQ**. Revoking deletes the row the control
plane has no disabled state, and a row that exists is a row that can sign in.
### Granting
On an instance record, **Members → Add**, choose an account member and an
instance role (`owner`, `admin`, `member`).
One person holds at most one user per instance, so granting twice is refused
rather than quietly creating a second user.
### Roles inside an instance
Independent of the account role. Someone can be an account `member` and an
instance `owner`, or the reverse.
### Revoking
Removes the user from the instance immediately. Any live session ends with the
session, since the user row backing it is gone.
:::warning Self-hosted instances cannot be granted from HQ
All three member endpoints refuse when the instance is self-hosted. Manage those
users in the instance itself, at **Settings → Access**.
:::
## Passwords
Your HQ password is the single source of truth for every user projected from it.
Changing it in the portal rehashes it and copies the hash to every instance you
have been granted.
Propagation is best-effort and immediate; a background pass compares and repairs
every 15 minutes, so a temporarily unreachable instance catches up on its own.
There is no local password-change endpoint for those users in the control plane,
so there is never a second writer for the hash.
:::warning HQ-managed users are read-only in the instance
Changing the role of, or deleting, an `hq`-sourced user inside the control plane
is refused with `409`. Do it from the portal. The UI shows those rows read-only
with a link back here, but the API is the boundary; the UI is the courtesy.
:::
+74
View File
@@ -0,0 +1,74 @@
---
id: self-hosted-instances
title: Self-hosted instances
sidebar_label: Self-hosted instances
---
A self-hosted instance is your install, licensed through HQ. HQ never touches
it: it issues a signed file that your install verifies locally.
## Free
Install first, then link and claim. Step by step in
[Claim a Free licence](../getting-started/claim-free-licence.md).
## Paid
Buying happens **before** the install has to exist, because you may well be
buying in order to build it.
```mermaid
flowchart LR
A["Buy in HQ"] --> B["Placeholder instance<br/>awaiting_link, no licence"]
B --> C["Install Vantage<br/>get its instance UUID"]
C --> D["Paste the UUID<br/>claim-link"]
D --> E["Licence issued<br/>bound to that UUID"]
```
1. **Overview → Buy self-hosted**, choose tier, term and configuration.
2. Complete checkout. HQ creates a **placeholder** instance in state
`awaiting_link` with no licence attached.
3. [Install Vantage](../getting-started/self-hosted-install.md) if you have not
already, and find its instance UUID at **Settings → Licence**.
4. Back in HQ, open the placeholder and paste the UUID.
5. The licence is issued, bound to that UUID. Download it and paste it into your
install.
:::info Why there is a placeholder at all
A licence binds to an instance UUID, and at the moment of payment that UUID may
not exist yet. Issuing early would mean issuing to nothing; refusing to sell
until you had installed would be the wrong order. The placeholder holds the
purchase until there is something to bind to.
:::
## Linking an existing install
If the install already exists, **Link an instance** takes the UUID directly. A
UUID already claimed by another account is refused with a conflict.
## Relinking
Rebuilding the host produces a new instance UUID, and the old licence no longer
matches. **Relink** moves the licence to the new UUID and reissues.
The number of relinks per term is capped, and the portal shows how many you have
left. This is not meant to obstruct disaster recovery if you have exhausted
them for a real reason, ask support.
## Installing the licence
Paste it at **Settings → Licence** in your install. The instance verifies the
signature and checks that the UUID matches its own.
Pasting works even while the current licence is expired that endpoint is
exempt from the licence check, because it is the route out of degraded mode.
## Keeping it current
Your install does not fetch licences. When a licence is reissued renewal,
configuration change, relink download the new one from HQ and paste it in.
:::warning Nothing reminds your install
The control plane knows only what its licence says. Expiry emails come from HQ,
to the account's address. Make sure someone reads them.
:::
+37
View File
@@ -0,0 +1,37 @@
---
id: index
title: Vantage documentation
sidebar_label: Overview
slug: /
---
# Vantage documentation
Vantage is a self-hosted, multi-tenant infrastructure control plane. It manages
SSH keys, runs scripted workflows, watches services, stores secrets, opens
browser consoles and applies OS updates across a fleet of servers.
A central server drives a lightweight agent installed on each managed machine.
The agent connects **outbound only**, so managed servers need no inbound
firewall holes.
## Where to start
| If you want to | Read |
| --------------------------------------- | --------------------------------------------------------------- |
| Understand what the pieces are | [What is Vantage](./getting-started/what-is-vantage.md) |
| Run it on your own hardware | [Self-hosted install](./getting-started/self-hosted-install.md) |
| Enrol your first machine | [Add your first server](./getting-started/first-server.md) |
| Manage your account, licence or billing | [Vantage HQ](./hq/accounts-and-signup.md) |
| Look something up | [Reference](./reference/environment-variables.md) |
## The two products
**Vantage** is the control plane the thing you sign in to in order to manage
servers. It runs either on your own infrastructure or as a cloud instance we
run for you.
**Vantage HQ** is the portal at `vantage-hq.hostxtra.co.uk` where you manage the
account behind those instances: who is on your team, which instances exist, what
licence each one holds and how it is billed. HQ never manages your servers, and
a Vantage instance never depends on HQ being reachable in order to run.
+77
View File
@@ -0,0 +1,77 @@
---
id: agent-updates
title: Agent updates
sidebar_label: Agent updates
---
Agents are versioned and released independently of the control plane, and update
themselves on command.
## Checking the current version
Each server's detail page shows the version it reported at its last sync.
`GET /api/agent/latest-version` reports the newest release available.
## Updating from the UI
**Servers → _a server_ → Update agent** pushes `UpdateAgentCmd` with a target
version. The agent then:
1. Downloads the binary for its platform from the release.
2. Verifies the SHA-256 against `checksums.txt`.
3. Stops itself, replaces the binary in place, and starts again.
`Restart=always` on the systemd unit is what makes the last step work.
The server briefly goes `offline` and comes back within a poll interval or two.
## Updating from the machine
There is a dynamic update script, the counterpart to the install one:
```bash
curl -fsSL https://vantage.example.com/update | bash
```
```powershell
irm https://vantage.example.com/update.ps1 | iex
```
It does the same download, checksum and replace, then restarts the service. Use
this when the control plane cannot push for example, when the machine is
reachable but its command stream is not.
## Rolling out across a fleet
There is no built-in bulk update. Two reasonable approaches:
- Update from each server's page, a few at a time.
- Build a [workflow](../vantage/workflows.md) whose step runs the update script,
and target it at the machines you want. That gives you ordering, failure
handling and a log.
:::tip Update a canary first
An agent that fails to start after replacing itself needs hands on that machine.
Do one, confirm it returns to `active`, then do the rest.
:::
## Version compatibility
The agent API is versioned to tolerate an agent older than the control plane. The
reverse an agent newer than the control plane is not a case anyone tests.
Upgrade the control plane first.
Agents report their version on every poll, so a fleet running mixed
versions is visible in the server list rather than something you have to go
looking for.
## If an update fails
| Symptom | Cause |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| "Checksum mismatch" | Interrupted download, or a proxy rewriting the body. Retry |
| Downloads nothing | The machine cannot reach `gitea.hostxtra.co.uk` |
| Service will not start afterwards | Wrong architecture binary, or the file was replaced while a different service manager held it. Reinstall with the install one-liner |
Reinstalling is always safe: the config file is left alone, so the agent comes
back with the same identity and token.
+89
View File
@@ -0,0 +1,89 @@
---
id: backups
title: Backups
sidebar_label: Backups
---
Two things matter: **MongoDB** and **`KEY_ENCRYPTION_KEY`**. A backup missing
either one restores to something unusable.
## What holds what
| Store | Contents | Back up |
| -------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| MongoDB | Everything durable servers, keys, assignments, workflows, runs, monitors, incidents, secrets, settings, audit | **Yes** |
| Redis | Sessions only | No. Losing it signs everyone out and nothing else |
| `./data` bind mount | Workflow run logs | Optional |
| `KEY_ENCRYPTION_KEY` | Not stored anywhere by the app | **Yes, separately** |
:::danger The database alone is not a backup
Private keys, vault secrets, OIDC client secrets and console credentials are
encrypted with `KEY_ENCRYPTION_KEY`, which lives in your environment file and
nowhere in the database. Restore the database without it and every one of those
values is permanently unreadable.
Store the key somewhere other than the server it protects.
:::
## Backing up MongoDB
With the bundled Mongo container:
```bash
docker compose exec -T mongo mongodump --archive --gzip --db vantage \
> /backups/vantage-$(date +%F).archive.gz
```
Restoring:
```bash
docker compose exec -T mongo mongorestore --archive --gzip --drop \
< /backups/vantage-2026-07-28.archive.gz
```
`--drop` replaces existing collections. Stop the `server` container first, so
nothing writes during the restore.
## Backing up the environment file
```bash
cp /opt/vantage/.env /secure-location/vantage.env
```
Treat it as a credential in its own right it holds the encryption key.
## Run logs
Workflow run logs live in the `./data` bind mount, not in the database. They are
swept on the retention schedule anyway, so most people do not back them up. If
you keep them for compliance, set retention to `0` (forever) and include the
directory.
## What a restore gives you
Everything: fleet, keys, assignments, workflows and their history, monitors and
incidents, secrets, settings and the audit log.
What it does **not** do is reconcile the world. After a restore:
- Agents reconnect with their existing tokens, since the token hashes are in the
database.
- If the restore is older than an enrolment, that server's token hash is missing
and the agent will fail to authenticate re-enrol it.
- The next agent poll rewrites `authorized_keys` to match the restored desired
state, which may remove keys added since the backup.
## A workable schedule
| What | When |
| ----------------- | ----------------------------------------------------- |
| MongoDB dump | Nightly, retained per your policy |
| Environment file | On change, held in a password manager or secret store |
| Restore rehearsal | Occasionally, into a throwaway host |
The rehearsal is the part that gets skipped and the part that finds the
problems.
## Cloud instances
We back these up. You do not need to.
+71
View File
@@ -0,0 +1,71 @@
---
id: upgrading
title: Upgrading
sidebar_label: Upgrading
---
Upgrading the control plane is a pull and a recreate. Agents are versioned and
upgraded separately see [Agent updates](./agent-updates.md).
:::info Cloud instances upgrade themselves
This page is for self-hosted installs. If your instance is hosted by us, there
is nothing here for you to do.
:::
## Upgrade
```bash
cd /opt/vantage
docker compose pull
docker compose up -d --remove-orphans
```
`--remove-orphans` clears containers for services that no longer exist in the
Compose file, which is what leaves a stale container running after a service is
renamed or removed.
## What happens on boot
1. **Migrations** run, recording markers so each runs once.
2. **Indexes** are ensured. Auth and settings index builders are fatal on
failure; secret and workflow ones only warn.
3. **Default steps** are reseeded from the image, overwriting the `default`
library which is why those steps are read-only.
Watch it:
```bash
docker compose logs -f server
```
## Before you upgrade
- **Back up MongoDB.** See [Backups](./backups.md). Migrations are one-way.
- **Read the release notes** for anything about migrations or environment
variables.
- **Check your `.env`** still supplies everything required. A newly required
variable stops the boot rather than defaulting to something unsafe.
## Downgrading
There is no automatic downgrade. Migrations do not roll back, so returning to an
older image means restoring the database backup taken before the upgrade. This
is the reason the backup is not optional.
## Zero-downtime
The stack is not designed for it. `docker compose up -d` recreates the server
container, which is a short interruption:
- Agents reconnect on their own they retry, and the poll loop is idempotent.
- Workflow runs in progress lose their command stream. Steps already dispatched
finish on the agent, but their results have nowhere to go. **Do not upgrade
during a run.**
- Sessions survive, because they live in Redis rather than in the server.
## After upgrading
- Confirm every service is `running`.
- Confirm servers return to `active` within a couple of poll intervals.
- Open a page that touches encryption a secret group to confirm
`KEY_ENCRYPTION_KEY` came through.
+111
View File
@@ -0,0 +1,111 @@
---
id: agent-config
title: Agent configuration
sidebar_label: Agent config
---
The agent reads no environment variables. Everything is in one YAML file.
## Location
| Platform | Path |
| -------- | ----------------------------------- |
| Linux | `/etc/vantage/config.yaml` |
| Windows | `%ProgramData%\vantage\config.yaml` |
Directory `0700`, file `0600`. The install script sets both.
## Contents
```yaml
server_url: "vantage.yourdomain.com:9090"
server_id: "<uuid>"
pre_reg_token: "<token>" # removed after the first successful Register()
agent_token: "" # written by the agent after Register()
poll_interval: 30s
tls: true
```
| Field | Meaning |
| --------------- | --------------------------------------------------------------------- |
| `server_url` | `host:port` of the gRPC endpoint. Comes from the server's `GRPC_HOST` |
| `server_id` | The identity issued when the enrolment was created |
| `pre_reg_token` | Single-use, one hour. Cleared once registration succeeds |
| `agent_token` | The permanent credential, written by the agent itself |
| `poll_interval` | How often the agent polls for key state. Default `30s` |
| `tls` | Whether to use TLS. Leave `true` |
:::danger This file is the credential
`agent_token` is plaintext here and nowhere else the control plane holds only
its SHA-256. Anyone who can read this file can act as this agent. That is why
it is `0600` and the directory is `0700`.
:::
## Startup sequence
```
1. Load the config
2. pre_reg_token present → register → save agent_token,
clear pre_reg_token, reconnect
3. Start: command stream · hourly update check · inventory · monitors
4. Enter the key poll loop
```
## The poll loop
```
1. Ask the control plane for the desired key state, reporting the
agent version
2. Non-Linux hosts stop here Windows agents register and heartbeat only
3. Diff the desired keys against /root/.ssh/authorized_keys;
unchanged → write nothing
4. Changed → write a temp file, rename it over the real one, chmod 0600
```
## Service management
### Linux
Unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, running
as root.
```bash
systemctl status vantage-agent
systemctl restart vantage-agent
journalctl -u vantage-agent -f
```
### Windows
A service registered through NSSM, or installed by the MSI that CI builds.
```powershell
Get-Service vantage-agent
Restart-Service vantage-agent
```
## Command-line flags
```
vantage-agent -generate-key
```
Generates a keypair locally. Normal operation takes no flags.
## Moving an agent to a new control plane
Change `server_url`, clear `agent_token`, set a fresh `pre_reg_token` from a new
enrolment, and restart. The old control plane still holds a server record that
will go `offline`; delete it there.
## Uninstalling
```bash
systemctl disable --now vantage-agent
rm -f /usr/local/bin/vantage-agent /etc/systemd/system/vantage-agent.service
rm -rf /etc/vantage
systemctl daemon-reload
```
Keys already written to `authorized_keys` remain on disk the agent is no
longer running to remove them. Revoke first if that matters.
@@ -0,0 +1,43 @@
---
id: environment-variables
title: Environment variables
sidebar_label: Environment variables
---
Everything the control plane reads from the environment, and what happens when
it is absent.
## Server
| Name | Required | Default | Notes |
| -------------------------- | --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GRPC_HOST` | **yes** | | The `host:port` agents dial. Boot fails without it. There is deliberately no fallback to the web host: that would hand every agent a port that does not speak gRPC |
| `MONGO_URI` | no | `mongodb://localhost:27017` | The database name is taken from the URI path, falling back to `vantage`. There is no separate `MONGO_DB` |
| `REDIS_ADDR` | no | `localhost:6379` | Sessions only |
| `KEY_ENCRYPTION_KEY` | yes in practice | | 64 hex characters (32 bytes) for AES-256-GCM. Required for private keys, vault secrets, OIDC client secrets and console credentials |
| `GITEA_HOST` | yes | `gitea.example.com` | Used to build the install scripts and agent download URLs. The default is a placeholder that will not resolve |
| `GUACD_ADDR` | no | `guacd:4822` | The [browser console](../vantage/browser-console.md) daemon |
| `APP_ROOT_LABEL` | no | `vantage` | The app root label for the host and session organisation guard |
| `VANTAGE_WORKFLOW_LOG_DIR` | no | | Where workflow run logs are written |
:::danger `KEY_ENCRYPTION_KEY` has no recovery path
It encrypts SSH private keys, vault secrets, OIDC client secrets and console
credentials. Lose it and all of them are unreadable. Back it up separately from
the database it protects.
:::
:::info A wrong `APP_ROOT_LABEL` fails quietly
It does not error. It simply stops matching, and the host/session guard stops
protecting anything.
:::
### Not configurable
The HTTP port (`8080`) and the gRPC port (`9090`) are fixed in the server. The
`HTTP_PORT` and `GRPC_PORT` entries in the shipped Compose file are inert —
remap with Docker's port publishing instead.
## Agent
The agent reads no environment variables. Everything is in its
[config file](./agent-config.md).
@@ -0,0 +1,85 @@
---
id: ports-and-networking
title: Ports and networking
sidebar_label: Ports and networking
---
## Control plane ports
| Port | Service | Who connects | Expose publicly |
| ------- | ----------- | -------------------------------- | --------------- |
| `3000` | web | Browsers, via your reverse proxy | Yes, behind TLS |
| `8080` | server REST | The web app | No |
| `9090` | server gRPC | Agents | **Yes** |
| `4822` | guacd | The server | No |
| `27017` | MongoDB | The server | No |
| `6379` | Redis | The server | No |
## Direction of travel
```mermaid
flowchart LR
B["Browser"] -->|HTTPS| P["Reverse proxy"]
P --> W["web :3000"]
W --> S["server :8080"]
A["Agent on a managed server"] -->|"gRPC/TLS :9090, outbound"| S
S --> G["guacd :4822"]
G -->|"SSH / RDP / VNC"| T["Target machine"]
```
Two things are worth reading off that diagram.
**Agents connect outbound.** No inbound rule is needed on a managed server, and
NAT is not an obstacle. The only requirement is that the machine can reach
`GRPC_HOST`.
**The console does not use the agent.** guacd connects directly to the target on
the protocol port. A machine reachable only by its agent behind NAT, on a
private subnet cannot be consoled, even though every other feature works.
## What to open
### On your firewall, inbound to the control plane
- Your web port, from wherever people are.
- `9090`, from every network holding managed machines.
### Outbound from the control plane
- `gitea.hostxtra.co.uk`, for agent releases and version checks.
- Anything a server-run [monitor](../vantage/monitors.md) checks.
- SMTP, if you use an SMTP notification channel.
- Protocol ports on machines you intend to console.
### Outbound from a managed machine
- `GRPC_HOST`.
- `gitea.hostxtra.co.uk`, for install and self-update.
- Its package mirrors, for OS updates.
## TLS
Terminate TLS for the web UI at your reverse proxy.
gRPC on `9090` is reached directly by agents with `tls: true`, so that port needs
a valid certificate for the name in `GRPC_HOST`. If you proxy it, the proxy must
speak HTTP/2 end to end many do not by default, and the symptom is agents that
register and then fail to hold the command stream.
## Reverse proxy notes
- Point the proxy at `web:3000`. The web app reaches the REST API internally, so
`8080` does not need publishing.
- The console uses a **WebSocket** at `/api/console/tunnel`. A proxy that does
not forward upgrade headers breaks the console and nothing else.
- Workflow log streaming is a long-lived response. A short proxy read timeout
truncates live logs while the run itself continues.
## Air-gapped and restricted networks
The control plane needs outbound access to fetch agent releases. Managed
machines need it too, unless you distribute the agent binary yourself and write
the config by hand the install script's only job is to do those two things.
Licence verification is entirely local, so a licensed install works with no
outbound access to HQ at all.
+149
View File
@@ -0,0 +1,149 @@
---
id: rest-api
title: REST API
sidebar_label: REST API
---
The control plane's HTTP API, on port `8080`. The web UI is a client of it and
has no privileges it does not.
## Authentication
Most endpoints take a session: an opaque 32-byte token in the `km_session`
cookie, with the body in Redis for 24 hours.
One endpoint takes a bearer token instead the External Secrets Operator read
path.
## Unauthenticated
```
GET /install /install.ps1 # dynamic agent install scripts
GET /update /update.ps1
GET /auth/bootstrap-status
POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me /auth/oidc/start /auth/oidc/callback
GET /api/secrets/:group/values # bearer token (ESO)
```
`/install` and `/install.ps1` take `server_id` and `token` as query parameters
and return a shell script with the newest agent version substituted in.
`POST /auth/bootstrap` works only while the database has no users.
## Session-authenticated, under `/api`
### Servers
```
GET,POST /servers
GET,POST /servers/new
GET,DELETE /servers/:id
POST /servers/:id/generate-key
POST /servers/:id/update-agent
POST /servers/:id/apply-updates
```
### Keys
```
GET,POST /keys
GET,DELETE /keys/:id
GET /keys/:id/private-key
POST /keys/:id/assign
DELETE /keys/:id/assign/:serverId
```
### Workflows and steps
```
GET,POST /steps
PUT,DELETE /steps/:id
GET /steps/:id/export
POST /steps/import · /steps/seed-defaults · /steps/parse
GET /steps/usage
GET,POST /workflows
GET,PUT,DELETE /workflows/:id
POST /workflows/:id/run
GET /workflows/:id/runs
GET /runs/:runId
POST /runs/:runId/cancel
GET /runs/:runId/servers/:serverId/logs
GET /runs/:runId/servers/:serverId/logs/stream
```
`PUT` and `DELETE` on a step whose source is `default` answer `409`. See
[Workflows](../vantage/workflows.md#default-steps).
### Monitors and channels
```
GET,POST /monitors
GET,PUT,DELETE /monitors/:id
GET /monitors/:id/incidents · /monitors/:id/uptime
GET,POST /channels
PUT,DELETE /channels/:id
POST /channels/:id/test
```
### Secrets
```
GET,POST /secrets
GET,PUT,DELETE /secrets/:group
POST /secrets/:group/reveal
DELETE /secrets/:group/:key
```
### Console
```
POST /console/connect
GET /console/tunnel # websocket
```
### Other
```
GET /audit
GET /agent/latest-version
GET,PUT /settings (owner|admin)
POST /settings/secrets-token (owner|admin)
GET /license
POST /license (self-hosted only)
GET,POST /org/users
PUT /org/users/:id/role
DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
```
## Notable refusals
| Endpoint | Condition | Status |
| -------------------------------------------------- | ------------------------------ | ------------------- |
| `POST /license` | deployment is `cloud` | `409 cloud_managed` |
| `PUT,DELETE /steps/:id` | the step's source is `default` | `409` |
| `PUT /org/users/:id/role`, `DELETE /org/users/:id` | the user's auth source is `hq` | `409` |
`POST /license` is exempt from the licence check, so pasting a valid licence
works while the current one is expired that is the way out of degraded mode.
## Multi-tenancy
Every request is scoped to the instance resolved from the session. On a
multi-tenant deployment, a request arriving at `<slug>.vantage.<tld>` also has
its host checked against the session's instance, and a mismatch is rejected.
## Errors
Errors are JSON with an `error` field. Customer-facing endpoints in the HQ API
answer `404` rather than `403` for another account's resource, because a `403`
confirms the resource exists; the control plane's own API is single-tenant per
session and does not need that distinction.
## Admin API
Vantage HQ is a separate hosted service with its own API and its own session.
Its behaviour is described in the [Vantage HQ](../hq/accounts-and-signup.md)
section rather than here; the two services share no session and no
authentication.
+133
View File
@@ -0,0 +1,133 @@
---
id: troubleshooting
title: Troubleshooting
sidebar_label: Troubleshooting
---
Symptoms, in the order people hit them.
## The server will not start
**Exits immediately on boot.** Almost always a missing `GRPC_HOST` the server
refuses to start rather than guess a value that would break every agent later.
**Fails during index creation.** The auth and settings index builders are fatal
on failure by design: those unique indexes are what enforce tenant isolation,
so starting without them is worse than not starting. Check the MongoDB user's
permissions and whether a conflicting index already exists.
**Starts, but every secret operation errors.** `KEY_ENCRYPTION_KEY` is missing
or is not 64 hex characters.
## Nobody can sign in
**`/setup` appears when users already exist.** The server is pointed at a
different database than you think. Check the database name in `MONGO_URI`
it comes from the URI path, not a separate variable.
**Sessions do not stick.** Redis is unreachable, or the cookie is being dropped
because the site is served over plain HTTP.
**"Wrong organisation" style rejections.** The host and session guard is
comparing the request host's label against the session's organisation. Check
`APP_ROOT_LABEL`.
**OIDC redirects and then fails.** The callback URL registered with the provider
must match exactly. Keep one local owner account so a broken provider is not a
lockout.
## A server never becomes active
Work through it in this order:
1. Is the agent running? `systemctl status vantage-agent`.
2. What does it say? `journalctl -u vantage-agent -f`.
3. Can that machine reach the endpoint? Test `GRPC_HOST` **from the machine**,
not from the control plane host.
4. Was the token already used or expired? It is single-use and lives one hour —
create a fresh enrolment rather than reusing the old command.
| Symptom | Cause |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Registers, then goes `offline` within minutes | Something permits the short `Register` call but drops the long-lived stream. Usually a proxy or idle-timeout middlebox |
| Stays `pending` forever | Registration never happened. Token spent, or the endpoint unreachable |
| Flaps between `active` and `offline` | Intermittent path, or a poll interval longer than the offline threshold |
Remember the offline sweep runs every two minutes, so status is never
instantaneous.
## Keys are not appearing on a machine
- **It is a Windows server.** Key management is Linux-only, by design.
- **The agent is not running.** Nothing polls, nothing writes.
- **The key is assigned but revoked.** Revocation is soft; check the assignment
state rather than the key.
- **Someone edited `authorized_keys` by hand.** The agent rewrites the file to
match the desired set; hand-added keys disappear on the next change.
## A workflow run fails or hangs
- **Hangs at dispatch.** The target's command stream is not connected the
server may be `offline`.
- **Fails immediately with an interpreter error.** A bash step on a Windows
target, or PowerShell on Linux.
- **A value does not reach the next step.** Values pass through the file at
`$WORKFLOW_ENV`, one `KEY=value` per line. Declaring an output does not export
it.
- **A secret is empty.** The group is not in the step's `secret_refs`, or the
key name differs from the environment variable you are reading.
- **Logs stop mid-run.** A reverse proxy read timeout cut the stream. The run
itself continues; reload the page.
## The console will not connect
| Symptom | Cause |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Connects, then closes at once | guacd unreachable. Check `GUACD_ADDR` and that the container is running |
| SSH rejects the key | The stored key has no private half, or is not on the target |
| RDP fails on retry | Credentials are single-use and consumed at tunnel open enter them again |
| Hangs at "connecting" | The **control plane** cannot reach the target on the protocol port. The agent's reachability is irrelevant here |
| Fails only in production | The reverse proxy is not forwarding WebSocket upgrade headers |
## Monitors report down when the service is up
- The check is running from the control plane and the endpoint is only reachable
internally. Switch the runner to an agent on a machine that can see it.
- The keyword no longer appears in the response body.
- Retries are `0`, so a single dropped packet flips the state.
## Notifications are not arriving
Use the channel **Test** button it goes through the real delivery path, so a
test that arrives proves credentials, network path and destination.
If the test fails: a webhook returning 300 or above counts as a failure, SMTP
needs `host`, `port`, `from` and `to`, and Telegram needs both `token` and
`chat_id`.
## Licence problems
| Symptom | Cause |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `409 cloud_managed` when pasting | It is a cloud instance. Licences are written by HQ; there is nothing to paste |
| Licence rejected as not matching | It is bound to a different instance UUID. Relink in HQ |
| Instance degraded despite a valid-looking licence | It has expired past its grace period. Pasting still works that endpoint stays available specifically so it can |
| Cannot enrol another server | The server allowance is reached. Raise it in HQ or remove one |
## HQ portal problems
The portal is a hosted service, so problems with it are ours to fix rather than
yours to configure. If a page fails to load, an action reports an error, or a
plan or price looks wrong after a change, contact support with your instance
UUID and roughly when it happened.
## Gathering information before asking for help
```bash
docker compose ps
docker compose logs --tail=200 server
journalctl -u vantage-agent --no-pager -n 200 # on the affected machine
```
Include your instance UUID from **Settings → Licence** it is the reference
support works from.
+51
View File
@@ -0,0 +1,51 @@
---
id: audit-log
title: Audit log
sidebar_label: Audit log
---
Every mutating API path writes an audit event. The log is at **Audit**.
## What an event carries
| Field | Meaning |
| ------ | -------------------------------------------------------- |
| Action | A dotted name, e.g. `server.created`, `settings.updated` |
| Actor | Who did it |
| Target | The object acted on |
| Detail | A short human-readable note |
| Time | When |
## What is recorded
Creation, modification and deletion across the product: servers and enrolments,
keys and assignments, workflow and step changes, runs triggered, monitors and
channels, secret groups and reveals, console sessions opened, settings and
member changes, licence installs.
Reads are not recorded, with one deliberate exception: **revealing a secret**
writes an event, because reading that particular thing is an act rather than a
lookup.
## What is not recorded
- Sign-ins and sign-out.
- Anything inside a console session.
- Step output. That lives in the run log, kept under the workflow retention
setting rather than with the audit log.
## Retention
Audit events are not swept by the workflow log retention setting that setting
governs run logs only. Audit history stays until the instance does.
:::warning It is a log, not a control
The audit log tells you what happened. It does not restrict what can happen, and
an admin can do anything an admin can do. Use roles for restriction and the log
for accountability.
:::
## Getting events out
`GET /api/audit` returns recent events as JSON and accepts a `limit`. There is
no streaming or push export; if you need events in a SIEM, poll that endpoint.
+70
View File
@@ -0,0 +1,70 @@
---
id: browser-console
title: Browser console
sidebar_label: Browser console
---
An SSH, RDP or VNC session in a browser tab, with no client software and no
inbound port on the target beyond the one the protocol already uses.
Protocol handling is Apache Guacamole's the control plane proxies a WebSocket
to a **guacd** daemon and manages credentials around it.
## Requirements
- `guacd` running and reachable from the server. The bundled Compose stack
includes it; `GUACD_ADDR` defaults to `guacd:4822`.
- `KEY_ENCRYPTION_KEY` set, since every credential involved is stored encrypted.
- Network reachability **from the control plane to the target** on the protocol
port. This is the one part of Vantage that is not agent-mediated: guacd
connects directly, so a machine reachable only by its agent cannot be
consoled.
## Opening a session
From a server's page, choose **Console**. Then:
1. The UI calls `POST /api/console/connect`, which mints a **one-time** session
token.
2. The browser opens a WebSocket to `GET /api/console/tunnel` with that token.
3. The server marks the token consumed atomically, so a second use cannot
race and proxies the connection to guacd.
## Credentials
### SSH
Authenticates with a private key stored in the [key library](./ssh-keys.md). The
key must have its private half uploaded; a public-only key cannot open a
session.
### RDP and VNC
You supply credentials when connecting. They are encrypted, **single-use**, and
consumed when the tunnel opens. They are not retained for the next session.
:::info Why single-use
A stored console credential is a standing grant to that machine for anyone who
can reach the endpoint. Consuming it at tunnel-open means a leaked session token
is worth one connection at most, and only until it is used.
:::
## Session behaviour
Closing the tab ends the session. There is no reconnect and no session
persistence reopening mints a new token and a new connection.
## Auditing
Opening a console is an audited action, with actor, server and time. What
happens _inside_ the session is not recorded: there is no session capture or
keystroke log. If you need that, it has to come from the target machine.
## When it does not work
| Symptom | Cause |
| -------------------------------- | ------------------------------------------------------------------------------- |
| Connects then closes immediately | guacd unreachable check `GUACD_ADDR` and that the container is up |
| SSH refuses the key | The stored key has no private half, or is not in the target's `authorized_keys` |
| RDP fails on a fresh credential | Credentials are consumed on open; a retry needs them entered again |
| Hangs at connecting | The control plane cannot reach the target on the protocol port |
+78
View File
@@ -0,0 +1,78 @@
---
id: monitors
title: Monitors
sidebar_label: Monitors
---
Monitors check that something is answering. Four types, two places they can run
from, and a notification path when they stop being satisfied.
## Types
| Type | Checks | Options |
| ------ | --------------------------------------- | --------------------------------------------------------------------------------- |
| `http` | An HTTP(S) URL | method, expected status, keyword that must appear in the body, allow insecure TLS |
| `tcp` | A host and port accept a connection | |
| `icmp` | A host answers ping | |
| `tls` | A certificate is valid and not expiring | warn N days before expiry |
An `http` monitor with a keyword is usually the one you want for an application:
a 200 that returns an error page still fails the keyword.
## Where a check runs
Every monitor has a **runner**:
| Runner | Meaning |
| ----------- | -------------------------------------------------------------- |
| `server` | The control plane's scheduler performs the check |
| a server ID | That server's agent performs it locally and reports the result |
Use `server` for anything reachable from the control plane public endpoints,
your own front door. Use an agent for anything only reachable from inside the
target network: a database on a private subnet, a service bound to localhost, a
device on a management VLAN.
:::tip Agent-run monitors measure what your users can't
A check run from the control plane tells you the service is reachable from
there. A check run on the machine tells you the process is alive. Those are
different questions, and outages usually live in the gap.
:::
## Interval, retries and state
- **Interval** how often to check.
- **Retries** how many consecutive failures are tolerated before the state
flips.
A monitor sits in `pending` until its first result. Failures accumulate; once
they exceed `retries`, the monitor goes `down`, an **incident** opens and the
attached notification channels fire. A subsequent success closes the incident.
Retries are what keeps one dropped packet from paging you. Set them to at least
`1` for anything crossing the public internet.
## Notifications
Attach one or more [notification channels](./notification-channels.md) to a
monitor. Channels are shared, so one Slack destination can serve every monitor
you have.
Notification state is tracked per monitor, so a service that is down for six
hours does not send a message per interval.
## Uptime and incidents
The monitor detail page shows:
- **Uptime**, from hourly rollup records checks performed, how many were up,
and mean latency per hour. Rollups are what make the graph cheap to draw over
long windows.
- **Incidents**, each with a start, a resolution and the cause recorded at the
moment it opened.
## Disabling versus deleting
Disabling stops the checks and keeps the history. Deleting removes the monitor.
Prefer disabling for anything seasonal the uptime record is usually the part
you wanted.
@@ -0,0 +1,101 @@
---
id: notification-channels
title: Notification channels
sidebar_label: Notification channels
---
A channel is a destination for alerts. [Monitors](./monitors.md) reference
channels by ID, so one destination serves as many monitors as you like.
Manage them at **Settings → Notifications**.
## Types
### Webhook
Posts JSON to a URL you choose.
| Setting | |
| ------- | -------- |
| `url` | Required |
```json
{
"monitor": "API front door",
"type": "http",
"old_status": "up",
"new_status": "down",
"message": "HTTP 502",
"time": "2026-07-28T09:14:02Z"
}
```
Any response of 300 or above counts as a delivery failure. The request times out
after 10 seconds.
### Discord
| Setting | |
| ------- | ------------------- |
| `url` | Discord webhook URL |
Posts the alert as message content.
### Slack
| Setting | |
| ------- | -------------------------- |
| `url` | Slack incoming webhook URL |
### Telegram
| Setting | |
| --------- | ----------- |
| `token` | Bot token |
| `chat_id` | Target chat |
### SMTP
| Setting | |
| ---------------------- | ---------------------------------------------------- |
| `host`, `port` | Required |
| `from`, `to` | Required |
| `username`, `password` | Optional; auth is skipped when the username is empty |
Port `465` uses implicit TLS; anything else uses STARTTLS.
Alert emails are rendered by the same email system that sends licence and
account mail, so a monitor alert and an account email look like the same
product.
## The message
Non-webhook channels all send the same one-line title:
```
[Vantage] API front door (http) is DOWN: HTTP 502
```
Recoveries read `recovered` in place of `is DOWN`. The webhook payload carries
the same information as fields, which is the one to use if you are routing into
something that needs to branch on status.
## Testing
Every channel has a **Test** button. It dispatches a fabricated down event for a
monitor called "Test monitor", through the real delivery path so a test that
arrives proves the credentials, the network path and the destination, not just
the configuration form.
:::tip Test after every change
Channel settings are only exercised when something breaks, which is the worst
time to discover a stale webhook URL. Re-test after rotating a token.
:::
## Choosing destinations
- Use a **chat channel** for awareness, and make sure someone owns it.
- Use **SMTP** where a durable record matters.
- Use a **webhook** to reach an on-call system that does escalation properly.
Vantage does not do escalation, rotas or acknowledgement; a webhook into
something that does is the intended answer.
+74
View File
@@ -0,0 +1,74 @@
---
id: secrets
title: Secrets vault
sidebar_label: Secrets
---
Key/value pairs, grouped by name, encrypted at rest with AES-256-GCM under
`KEY_ENCRYPTION_KEY`. Two things consume them: workflow steps, and Kubernetes
External Secrets Operator.
## Groups and values
A **group** is a named bundle `prod-db`, `registry`, `acme-api`. Inside it are
key/value pairs.
Group by consumer, not by type. A group is the unit a workflow step references
and the unit ESO reads, so a group that matches one consumer is one reference;
a group holding everything is over-sharing to every step that needs any of it.
## Managing them
**Secrets → New group**, then add keys.
Values are write-then-hidden. The list shows keys, never values. **Reveal** is a
separate action on a separate endpoint, and it writes an audit event so
looking at a secret is a recorded act.
Deleting a single key and deleting the whole group are separate operations.
## Using secrets in workflows
Add the group name to a step's `secret_refs`. At execution the group's pairs are
injected into the step's environment:
```bash
# secret_refs: ["registry"]
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
```
A workflow can also override `secret_refs` per step, without changing the
library entry.
:::warning A step can print its own secrets
Injection puts values in the environment. If your script echoes them, or runs
with `set -x`, they land in the run log which is stored on disk and readable
in the UI. Vantage does not scrub step output.
:::
## Kubernetes External Secrets Operator
`GET /api/secrets/:group/values` returns a group's pairs for ESO, authenticated
with a **bearer token** rather than a session.
1. Generate the token at **Settings → Integrations**. It is shown once; only its
SHA-256 is stored.
2. Put it in a Kubernetes secret.
3. Point an ESO `SecretStore` at the endpoint with that bearer token.
The token is rotatable: generating a new one replaces the stored hash and
invalidates the old one immediately.
:::danger This token reads every group
It is instance-wide, not scoped to one group. Treat it as a credential to the
whole vault: store it as a secret in the cluster, never in a manifest in git,
and rotate it when anyone with access leaves.
:::
## What the vault is not
- **Not a password manager.** There is no sharing, expiry or per-user
visibility. Anyone who can sign in and reveal, can reveal.
- **Not versioned.** Overwriting a value loses the previous one.
- **Not recoverable without the key.** If `KEY_ENCRYPTION_KEY` is lost, so is
every value. Back it up separately from the database.
+98
View File
@@ -0,0 +1,98 @@
---
id: servers
title: Servers
sidebar_label: Servers
---
The fleet. Every managed machine runs an agent that connects outbound to the
control plane, and everything else in Vantage keys, workflows, monitors,
consoles targets these records.
## Enrolling a server
Covered step by step in [Add your first server](../getting-started/first-server.md).
In short: **Servers → Add server** issues a single-use, one-hour token and shows
a one-liner to run as root on the target machine.
## Lifecycle
| Status | Meaning |
| --------- | --------------------------------------------------- |
| `pending` | Enrolment created; the agent has not registered yet |
| `active` | The agent registered and is syncing |
| `offline` | Last-seen passed the threshold |
The offline sweep runs every two minutes, so a machine that has just gone away
takes a little while to be marked as such. That delay is intentional a single
missed poll is not an outage.
## The server detail page
### Keys
Which SSH keys are assigned to this machine, and their state. See
[SSH keys](./ssh-keys.md).
### Inventory
Agents report:
| Data | Refreshed |
| ---------------------------------------- | ---------------- |
| CPU, memory, swap, load | every 30 seconds |
| Partitions, kernel, full static snapshot | every 15 minutes |
The two carry separate timestamps, so a stale static snapshot beside fresh
metrics is normal rather than a fault.
### OS updates
Agents check for pending package updates hourly and report the count. From the
server page you can:
- **Apply updates** pushes `ApplyUpdatesCmd` down the command stream. The
agent runs the platform's package manager and reports back.
- **Update agent** pushes `UpdateAgentCmd` with a target version; the agent
downloads the release, verifies it and replaces itself. See
[Agent updates](../operations/agent-updates.md).
:::warning Applying updates is not scheduled or staged
It runs now, on that machine. If you need ordering, health gates or a canary,
build it as a [workflow](./workflows.md) instead that is what workflows exist
for.
:::
### Console
Opens a browser SSH, RDP or VNC session. See [Browser console](./browser-console.md).
## Windows servers
Windows agents register, heartbeat, run workflow steps and report inventory.
They do not manage `authorized_keys` the poll loop stops after the heartbeat
on any non-Linux host. This is a deliberate scope decision, not a gap being
worked on.
## Removing a server
Deleting the server record removes it from the fleet. It does **not** uninstall
the agent, which will keep trying to sync and failing. Uninstall it on the
machine too:
```bash
systemctl disable --now vantage-agent
rm -f /usr/local/bin/vantage-agent /etc/systemd/system/vantage-agent.service
rm -rf /etc/vantage
systemctl daemon-reload
```
Keys previously written to `authorized_keys` stay on disk, because the agent is
no longer running to remove them. Revoke and let the agent apply the change
**before** you delete the server if that matters to you.
## Agent tokens
Each server has its own token. The control plane stores only its SHA-256; the
plaintext exists in the agent's `0600` config and nowhere else. There is no way
to read a token back out of the control plane if one is lost, re-enrol the
machine.
+113
View File
@@ -0,0 +1,113 @@
---
id: settings
title: Settings
sidebar_label: Settings
---
One page, three groups: **Access**, **Monitoring** and **Integrations**. Plus
the licence, which has its own page.
Settings require the `owner` or `admin` role.
:::info Where instance settings went
Members and single sign-on used to live at `/settings/instance`. They are now
the Access group at the top of this page splitting "who can sign in" from "how
this instance behaves" produced two half-pages and a nav entry nobody could
distinguish from Settings. The old path still redirects.
:::
## Access
### Members
Add, remove and re-role the people who can sign in.
| Role | Can |
| -------- | ---------------------------------------------------- |
| `owner` | Everything |
| `admin` | Everything except owner-only settings |
| `member` | Servers, keys, workflows, monitors, secrets, console |
Local members authenticate with email and a bcrypt-hashed password.
#### Members managed by Vantage HQ
On a cloud instance, people granted access from the HQ portal appear here as
read-only rows with a link to the portal.
:::warning HQ-managed users cannot be edited locally
Changing the role of, or deleting, an `hq`-sourced user is refused with `409`.
HQ owns their role, their password and whether they exist at all a local
change would be overwritten by the next sync and would leave two writers for one
password hash. Manage them from [People and roles](../hq/people-and-roles.md).
:::
### Single sign-on (OIDC)
Configured per organisation:
| Field | |
| ------------- | ---------------------------- |
| Issuer | Your provider's issuer URL |
| Client ID | |
| Client secret | Stored AES-256-GCM encrypted |
Sign-in then goes `/auth/oidc/start` → your provider → `/auth/oidc/callback`.
Local and OIDC users coexist. Keep at least one local owner: if the provider is
misconfigured or unreachable, a local account is the way back in.
## Monitoring
- **Alert defaults** for monitors.
- **Notification channels** their own page. See
[Notification channels](./notification-channels.md).
## Integrations
### Workflow log retention
How long run logs are kept.
| Value | Meaning |
| -------- | -------------- |
| unset | 30 days |
| a number | that many days |
| `0` | forever |
### ESO read token
The bearer token External Secrets Operator uses to read secret groups. Shown
once, stored as a SHA-256 hash, rotatable. See
[Secrets](./secrets.md#kubernetes-external-secrets-operator).
## Licence
`/settings/license` shows the deployment, tier, server allowance, enabled
features and expiry.
On **self-hosted**, paste a licence here. This works even while the current
licence is expired that is the way out of degraded mode.
On **cloud**, there is no paste form. The endpoint answers `409 cloud_managed`,
because a cloud licence is written by HQ directly. The page links to the portal
instead.
See [Licensing and entitlements](../hq/licensing-and-entitlements.md).
## Sessions
Sessions are an opaque token in the `km_session` cookie, held in Redis with a
24-hour TTL. There is no per-session management UI; restarting Redis signs
everyone out and affects nothing else.
## Host and organisation guard
On a multi-tenant deployment, a request to `<slug>.vantage.<tld>` resolves the
organisation from the slug and rejects a session belonging to a different one.
The label it looks for comes from `APP_ROOT_LABEL`.
:::warning A wrong `APP_ROOT_LABEL` disables the guard
It does not fail loudly it simply stops matching, and the host check stops
protecting anything. If you serve the UI on a custom domain, set it to match.
:::
+81
View File
@@ -0,0 +1,81 @@
---
id: ssh-keys
title: SSH keys
sidebar_label: SSH keys
---
Vantage holds a library of public keys and decides, per server, which ones
belong in `/root/.ssh/authorized_keys`. The agent makes the file match.
:::info root only
Vantage manages `/root/.ssh/authorized_keys` and nothing else. There is no
per-user key management. The agent runs as root because writing that file
requires it.
:::
## Adding a key
### Upload one you already have
**Keys → Add key**, paste the public half. Vantage stores the public key and its
fingerprint, and never needs the private half for this path.
### Generate one on a server
Vantage can have an agent generate a keypair on a managed machine
(`GenerateKeyCmd` over the command stream). The public half comes back to the
library. You may optionally upload the private half too, in which case it is
stored **AES-256-GCM encrypted** under `KEY_ENCRYPTION_KEY`.
The JSON representation of a key exposes only `has_private_key` and
`has_passphrase` never the material. Retrieving a stored private key is its
own endpoint and its own audit event.
:::tip Why store a private key at all
The [browser console](./browser-console.md) needs one to open an SSH session. If
you are not using the console, do not upload private halves.
:::
## Assigning
Assign a key to one or more servers. Within one poll interval 30 seconds the
agent picks up the change.
## Revoking
Revocation is **soft**: the assignment gets a `revoked_at` timestamp rather than
being deleted, so the history of who had access to what, and when, survives.
The agent treats a revoked assignment as "not desired" and removes the line from
`authorized_keys` on its next sync.
:::warning Revoking does not close open sessions
It removes the key from the file. An SSH session already established stays up
until it ends. Kill sessions on the machine if that matters.
:::
## What the agent actually does
Each poll:
1. The control plane returns the desired set of public keys for that server.
2. The agent reads `/root/.ssh/authorized_keys` and computes fingerprints.
3. **If the sets match, it writes nothing.** No disk churn on unchanged state,
which is most polls.
4. If they differ, it writes a temporary file, then `os.Rename()`s it over the
real one and sets mode `0600`.
The rename is atomic, so a machine that dies mid-write keeps the old file
intact. There is no window in which `authorized_keys` is truncated or partial.
:::danger Vantage owns the whole file
The agent rewrites `authorized_keys` to match the desired set. Keys added by
hand on the machine are removed on the next change. If a key must survive, put
it in Vantage.
:::
## Recovering from a lockout
If you have removed every key from a machine and cannot get in, you still have
the console provided a private key is stored or out-of-band access from your
hosting provider. Vantage has no backdoor and does not keep a break-glass key.
+141
View File
@@ -0,0 +1,141 @@
---
id: workflows
title: Workflows and steps
sidebar_label: Workflows
---
A **step** is a reusable script with declared inputs, outputs and secret
references. A **workflow** composes steps in order and targets a set of servers.
Running one dispatches the steps to each target's agent and streams the output
back live.
## Steps
A step has:
| Field | Meaning |
| --------------------- | ----------------------------------------------- |
| `name`, `description` | Library identity |
| `interpreter` | `bash` or `powershell` |
| `script` | The body |
| `declared_inputs` | Named parameters with defaults and descriptions |
| `declared_outputs` | Names this step promises to export |
| `secret_refs` | Vault entries injected as environment variables |
### Passing values between steps
Each step runs with `WORKFLOW_ENV` set to a file path. Anything written there as
`KEY=value` becomes an environment variable for the **later steps of the same
run on the same server**.
```bash
HOSTNAME=$(hostname)
echo "$HOSTNAME"
echo "HOSTNAME=$HOSTNAME" >> $WORKFLOW_ENV
```
That is the whole mechanism. `declared_outputs` documents what a step exports so
the designer can show it; the file is what actually carries the value.
### Secrets
List a vault group in `secret_refs` and its key/value pairs are injected as
environment variables when the step runs. They are not written to the run log
unless your own script echoes them. See [Secrets](./secrets.md).
### The workspace
Every run gets a per-run working directory on each target. Steps share it, so
one step can leave a file for the next. The agent removes it at the end of the
run (`CleanupWorkspaceCmd`).
Do not use it for anything that must outlive the run.
## Default steps
A small library is seeded into every organisation at boot from the image, so a
new install is not staring at an empty page.
:::warning Default steps are read-only
Editing or deleting one is refused with `409`. Seeding rewrites them on every
boot, so an edit would silently revert and a delete would come back at the next
restart refusing is the honest answer.
To customise one, use the per-step **script override** in the workflow designer,
which belongs to that workflow and is not touched by seeding. To add to the
shared library permanently, a file has to be committed to the repository and the
server image rebuilt.
:::
The UI mirrors this the step modal opens read-only and Delete is hidden but
the API is the boundary; the UI is the courtesy.
## Building a workflow
1. **Workflows → New**.
2. Add steps in order from the library.
3. Set inputs per step.
4. Set failure behaviour per step.
5. Choose target servers.
### Failure behaviour
| `on_failure` | Effect |
| ------------ | --------------------------------------------------------------- |
| `stop` | Abort this server's run. Other servers continue |
| `continue` | Record the failure, run the next step anyway |
| `retry` | Re-run the step up to `max_retries`, then treat it as a failure |
### Per-step overrides
A workflow can override a step's script or its secret references without
touching the library entry. This is how you adapt a default step, and it is
scoped to that workflow.
## Running
**Run** snapshots the resolved steps into the run record and dispatches each step
to the target's agent over the command stream no waiting for the next poll.
:::info Runs freeze their steps
The snapshot is why editing a step tomorrow never rewrites what happened today.
A run shows the script that actually executed, not the current library version.
:::
Targets run **in parallel**; steps within one server run **in order**.
## Watching a run
Step stdout and stderr stream back as chunks, are appended to a log file on the
server, and the UI follows them live. Each step records status, attempts, exit
code and its exported environment.
**Cancel** stops a run in progress. Steps already running on an agent finish;
nothing further is dispatched.
## Log retention
Run logs are swept on a schedule set by `workflow_log_retention_days` in
Settings:
| Value | Meaning |
| -------- | -------------- |
| unset | 30 days |
| a number | that many days |
| `0` | keep forever |
## Import and export
Steps export to a JSON file (`vantage.step/v1`) and import back, which is how
you move a step between instances or keep one in version control. There is also
a parse endpoint that turns a pasted script into a draft step by reading its
declared inputs and outputs.
## Practical notes
- A step is a script. It runs as root, on the target, with no sandbox. Review
what you import.
- Keep steps small and single-purpose; compose them in the workflow. That is
what makes the library reusable rather than a folder of near-duplicates.
- PowerShell steps only make sense on Windows targets and bash steps on Linux
ones. Nothing stops you targeting the wrong one; the step simply fails.
+123
View File
@@ -0,0 +1,123 @@
import type * as Preset from "@docusaurus/preset-classic";
import type { Config } from "@docusaurus/types";
import { themes as prismThemes } from "prism-react-renderer";
// Served as a path on the marketing host (vantage.hostxtra.co.uk/docs), routed
// by its own Nginx Proxy Manager location rather than by site/. A path and not
// a subdomain on purpose: *.vantage.hostxtra.co.uk is the per-tenant instance
// namespace, and APP_ROOT_LABEL resolves an org from the label before
// "vantage", so a docs. label there would be read as a tenant slug.
//
// DOCS_BASE_URL has to agree with three things at once: the NPM location, the
// directory the runtime image copies the build into, and this value. When they
// disagree the HTML still loads and every asset 404s.
const url = process.env.DOCS_URL || "https://vantage.hostxtra.co.uk";
const baseUrl = process.env.DOCS_BASE_URL || "/docs/";
const appUrl = process.env.APP_URL || "https://vantage.hostxtra.co.uk";
const hqUrl = process.env.HQ_URL || "https://vantage-hq.hostxtra.co.uk";
const config: Config = {
title: "Vantage Docs",
tagline: "Fleet management for servers you actually own",
favicon: "img/favicon.svg",
url,
baseUrl,
organizationName: "hostxtra",
projectName: "vantage",
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
i18n: { defaultLocale: "en", locales: ["en"] },
markdown: {
mermaid: true,
hooks: { onBrokenMarkdownLinks: "throw" },
},
themes: [
"@docusaurus/theme-mermaid",
[
// Compile-time index served from this origin. No Algolia account,
// no external host, nothing to key or rotate.
"@easyops-cn/docusaurus-search-local",
{
hashed: true,
indexBlog: false,
docsRouteBasePath: "/",
highlightSearchTermsOnTargetPage: true,
},
],
],
presets: [
[
"classic",
{
docs: {
// Docs-only mode: the documentation is the site.
routeBasePath: "/",
sidebarPath: "./sidebars.ts",
},
blog: false,
theme: { customCss: "./src/css/custom.css" },
} satisfies Preset.Options,
],
],
themeConfig: {
colorMode: {
// Light default, matching site/ and adminsite/. web/ is the only
// app locked to dark, and that contrast is deliberate.
defaultMode: "light",
respectPrefersColorScheme: true,
},
navbar: {
// Wordmark only. The logo mark is drawn with currentColor in
// site/components/Logo.tsx, which an <img> src cannot inherit, and
// baking a fill into the file would mean a hex that stops tracking
// the theme.
title: "Vantage",
items: [
{
type: "docSidebar",
sidebarId: "docs",
position: "left",
label: "Documentation",
},
{ href: appUrl, label: "Control plane", position: "right" },
{ href: hqUrl, label: "Vantage HQ", position: "right" },
],
},
footer: {
style: "light",
links: [
{
title: "Documentation",
items: [
{ label: "Getting started", to: "/getting-started/what-is-vantage" },
{ label: "Vantage", to: "/vantage/servers" },
{ label: "Vantage HQ", to: "/hq/accounts-and-signup" },
{ label: "Reference", to: "/reference/environment-variables" },
],
},
{
title: "Product",
items: [
{ label: "Control plane", href: appUrl },
{ label: "Vantage HQ", href: hqUrl },
],
},
],
copyright: `© ${new Date().getFullYear()} HostXtra.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
additionalLanguages: ["bash", "powershell", "yaml", "json", "protobuf", "nginx"],
},
} satisfies Preset.ThemeConfig,
};
export default config;
+32
View File
@@ -0,0 +1,32 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Hitting the container directly is a 403 on an empty document root
# otherwise, which reads as a broken deploy rather than "wrong path".
location = / {
return 302 /docs/;
}
# Hashed assets are immutable — the filename changes when the content does.
location /docs/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /docs/ {
# Docusaurus emits a real page per route, so a miss is a genuine 404
# rather than something a SPA fallback should paper over.
try_files $uri $uri/ $uri.html /docs/404.html;
}
error_page 404 /docs/404.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
+20082
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "vantage-docs",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "docusaurus start",
"build": "docusaurus build",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "^3.10.2",
"@docusaurus/preset-classic": "^3.10.2",
"@docusaurus/theme-mermaid": "^3.10.2",
"@easyops-cn/docusaurus-search-local": "^0.52.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@docusaurus/types": "^3.10.2",
"typescript": "~5.6.2"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
// Authored by hand rather than autogenerated, so ordering is a decision and
// not a filename accident.
const sidebars: SidebarsConfig = {
docs: [
"index",
{
type: "category",
label: "Getting started",
collapsed: false,
items: [
"getting-started/what-is-vantage",
"getting-started/cloud-vs-self-hosted",
"getting-started/self-hosted-install",
"getting-started/first-login",
"getting-started/first-server",
"getting-started/claim-free-licence",
],
},
{
type: "category",
label: "Vantage",
items: [
"vantage/servers",
"vantage/ssh-keys",
"vantage/workflows",
"vantage/monitors",
"vantage/notification-channels",
"vantage/secrets",
"vantage/browser-console",
"vantage/audit-log",
"vantage/settings",
],
},
{
type: "category",
label: "Vantage HQ",
items: [
"hq/accounts-and-signup",
"hq/people-and-roles",
"hq/cloud-instances",
"hq/self-hosted-instances",
"hq/licensing-and-entitlements",
"hq/billing",
"hq/free-tier",
],
},
{
type: "category",
label: "Reference",
items: [
"reference/environment-variables",
"reference/rest-api",
"reference/agent-config",
"reference/ports-and-networking",
"reference/troubleshooting",
],
},
{
type: "category",
label: "Operations",
items: [
"operations/upgrading",
"operations/backups",
"operations/agent-updates",
"operations/ci-cd",
],
},
],
};
export default sidebars;
+236
View File
@@ -0,0 +1,236 @@
/* ==========================================================================
Vantage documentation design tokens
The token block below is COPIED VERBATIM from site/app/globals.css — same
names, same values. adminsite/ holds a copy too, web/ holds the dark half,
and shared/mail/templates/layout.html.tmpl holds it a fifth time as literal
hex because email clients support neither var() nor prefers-color-scheme.
Nothing enforces the match automatically: change a token in one file and you
change it in all of them, in the same commit.
Everything below the token block maps Docusaurus's --ifm-* variables onto
these. No rule in this file, and no component in this app, may carry a hex
value outside the two token blocks.
Docusaurus always stamps data-theme on <html>, so unlike site/ there is no
prefers-color-scheme branch to keep in step — the theme toggle is the only
writer.
========================================================================== */
:root,
:root[data-theme="light"] {
--ground: #eaedf3;
--panel: #ffffff;
--panel-2: #f4f6fa;
--ink: #0a1b33;
--ink-2: #41556f;
--ink-3: #6c7f96;
--rule: #cdd6e2;
--rule-soft: #e0e6ef;
--accent: #0b2a58;
--accent-ink: #ffffff;
--up: #2f8a60;
--down: #c6462f;
--pend: #b0801f;
--shadow: 0 1px 0 rgba(10, 27, 51, 0.05), 0 18px 40px -26px rgba(10, 27, 51, 0.45);
--logo: #0b2a58;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--s--1: clamp(0.76rem, 0.74rem + 0.1vw, 0.81rem);
--s-0: clamp(1rem, 0.97rem + 0.14vw, 1.05rem);
--s-1: clamp(1.16rem, 1.09rem + 0.32vw, 1.36rem);
--s-2: clamp(1.5rem, 1.34rem + 0.74vw, 2rem);
--s-3: clamp(2rem, 1.66rem + 1.6vw, 3.1rem);
--s-4: clamp(2.6rem, 1.9rem + 3.3vw, 4.9rem);
--rail: 1200px;
}
:root[data-theme="dark"] {
--ground: #071628;
--panel: #0d2138;
--panel-2: #102842;
--ink: #e4ecf6;
--ink-2: #9fb3ca;
--ink-3: #71879f;
--rule: #1e3855;
--rule-soft: #172c44;
--accent: #5b9be8;
--accent-ink: #04101f;
--up: #4fb484;
--down: #e2705a;
--pend: #d6a63f;
--shadow: 0 1px 0 rgba(0, 0, 0, 0.35), 0 20px 44px -26px rgba(0, 0, 0, 0.85);
--logo: #7fb2f0;
}
/* ---------- Docusaurus mapping ---------- */
:root {
--ifm-color-primary: var(--accent);
--ifm-color-primary-dark: var(--accent);
--ifm-color-primary-darker: var(--accent);
--ifm-color-primary-darkest: var(--accent);
--ifm-color-primary-light: var(--accent);
--ifm-color-primary-lighter: var(--accent);
--ifm-color-primary-lightest: var(--accent);
--ifm-background-color: var(--ground);
--ifm-background-surface-color: var(--panel);
--ifm-font-family-base: var(--sans);
--ifm-font-family-monospace: var(--mono);
--ifm-font-size-base: var(--s-0);
--ifm-line-height-base: 1.65;
--ifm-heading-font-weight: 800;
--ifm-heading-line-height: 1.15;
--ifm-h1-font-size: var(--s-3);
--ifm-h2-font-size: var(--s-2);
--ifm-h3-font-size: var(--s-1);
--ifm-font-color-base: var(--ink);
--ifm-heading-color: var(--ink);
--ifm-link-color: var(--accent);
--ifm-link-hover-color: var(--accent);
--ifm-toc-link-color: var(--ink-2);
--ifm-navbar-background-color: var(--panel);
--ifm-navbar-shadow: none;
--ifm-navbar-link-color: var(--ink-2);
--ifm-navbar-link-hover-color: var(--ink);
--ifm-footer-background-color: var(--panel-2);
--ifm-footer-color: var(--ink-2);
--ifm-footer-link-color: var(--ink-2);
--ifm-footer-title-color: var(--ink);
--ifm-menu-color: var(--ink-2);
--ifm-menu-color-active: var(--accent);
--ifm-menu-color-background-active: var(--panel-2);
--ifm-menu-color-background-hover: var(--panel-2);
--ifm-toc-border-color: var(--rule-soft);
--ifm-hr-border-color: var(--rule-soft);
--ifm-table-border-color: var(--rule-soft);
--ifm-table-stripe-background: var(--panel-2);
--ifm-table-head-background: var(--panel-2);
--ifm-code-background: var(--panel-2);
--ifm-code-font-size: 0.88em;
--ifm-pre-background: var(--panel-2);
--ifm-blockquote-color: var(--ink-2);
--ifm-blockquote-border-color: var(--rule);
--ifm-global-radius: 4px;
--ifm-alert-border-radius: 4px;
--ifm-button-border-radius: 4px;
--ifm-global-shadow-lw: var(--shadow);
--ifm-global-shadow-md: var(--shadow);
--ifm-breadcrumb-color-active: var(--accent);
--ifm-breadcrumb-item-background-active: var(--panel-2);
--docusaurus-highlighted-code-line-bg: var(--rule-soft);
}
/* Admonitions carry the semantic three. They read by shape and label as well
as colour, which is the rule everywhere else in the product too. */
.theme-admonition-note,
.theme-admonition-info {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--rule);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-tip {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--up);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-warning {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--pend);
--ifm-alert-foreground-color: var(--ink);
}
.theme-admonition-danger {
--ifm-alert-background-color: var(--panel-2);
--ifm-alert-border-color: var(--down);
--ifm-alert-foreground-color: var(--ink);
}
/* ---------- small corrections ---------- */
.navbar {
border-bottom: 1px solid var(--rule-soft);
}
.navbar__title {
font-weight: 800;
letter-spacing: -0.02em;
}
.footer {
border-top: 1px solid var(--rule-soft);
}
.markdown h1,
.markdown h2,
.markdown h3 {
letter-spacing: -0.03em;
text-wrap: balance;
}
.markdown h2 {
margin-top: 2.4rem;
padding-top: 1.6rem;
border-top: 1px solid var(--rule-soft);
}
.markdown > p,
.markdown li {
color: var(--ink-2);
}
.markdown strong {
color: var(--ink);
}
/* Machine output — install one-liners, key blobs, run logs — sits on a floor
beneath the panel, the same distinction web/ draws with --well. */
.theme-code-block {
border: 1px solid var(--rule-soft);
box-shadow: none !important;
}
table {
display: table;
width: 100%;
}
.menu {
font-size: var(--s--1);
padding: 1rem;
}
.menu__list-item-collapsible .menu__link--sane,
.menu__link {
border-radius: 4px;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* The reference tables in Reference/ are wide by nature; they scroll inside
their own container rather than pushing the page sideways. */
.markdown table {
display: block;
overflow-x: auto;
max-width: 100%;
}
+12
View File
@@ -0,0 +1,12 @@
<!--
The Vantage mark, traced from site/components/Logo.tsx. A favicon is an asset
rather than a component, and a browser tab has no access to the token block,
so the logo navy is a literal here — the same concession the email layout
makes. Keep it in step with --logo.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="246 207 533 610">
<g transform="translate(0,1024) scale(0.1,-0.1)" fill="#0b2a58" stroke="none">
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187 -702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559 1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459 272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84 -1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297 -331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0 -1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204 -447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256 -13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333 805 476 1149 677 1156 677 4 0 56 -29 116 -64z" />
<path d="M3760 6109 c0 -6 187 -396 417 -867 229 -471 483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160 160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73 -158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838 l-182 390 -287 3 c-202 2 -286 -1 -286 -9z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
},
"exclude": [".docusaurus", "build"]
}
+1
View File
@@ -2,6 +2,7 @@ go 1.26
use (
./admin
./agent
./server
./shared
./sitesvc
+18 -8
View File
@@ -2,14 +2,14 @@ syntax = "proto3";
package vantage.v1;
option go_package = "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb";
option go_package="gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb";
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
// Bidirectional stream: agent sends auth once, server pushes commands.
@@ -63,7 +63,9 @@ message AgentMessage {
}
}
message AgentReady {}
message AgentReady {
}
message CommandResult {
string command_id = 1;
@@ -83,7 +85,9 @@ message ReportUpdatesRequest {
repeated PackageUpdate updates = 3;
}
message ReportUpdatesResponse {}
message ReportUpdatesResponse {
}
message CPUReport {
string model = 1;
@@ -117,7 +121,9 @@ message InventoryReport {
string kernel = 9;
}
message InventoryReportResponse {}
message InventoryReportResponse {
}
message MonitorSpec {
string monitor_id = 1;
@@ -157,9 +163,13 @@ message ReportChecksRequest {
repeated CheckResult results = 3;
}
message ReportChecksResponse {}
message ReportChecksResponse {
message ApplyUpdatesCmd {}
}
message ApplyUpdatesCmd {
}
message ServerCommand {
string command_id = 1;
@@ -201,7 +211,7 @@ message RunStepCmd {
string script = 2;
map<string, string> env = 3;
int32 timeout_seconds = 4;
string workspace_id = 5; // per-run working dir the agent creates & uses as cwd
string workspace_id = 5;
}
message StepResult {
+3
View File
@@ -24,6 +24,9 @@ FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /vantage-server /vantage-server
COPY default_steps/ /opt/default-steps/
ENV VANTAGE_DEFAULT_STEPS_DIR=/opt/default-steps
EXPOSE 8080 9090
ENTRYPOINT ["/vantage-server"]
+1 -8
View File
@@ -139,10 +139,6 @@ func newServer(c *gin.Context) {
}
services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
host := publicHostFromRequest(c)
@@ -392,10 +388,7 @@ func applyUpdates(c *gin.Context) {
}
func handleUpdateScript(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
giteaHost := "gitea.hostxtra.co.uk"
script := fmt.Sprintf(`#!/usr/bin/env bash
set -euo pipefail
+2 -8
View File
@@ -12,10 +12,7 @@ func handleInstallScriptWindows(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
giteaHost := "gitea.hostxtra.co.uk"
grpcHost := os.Getenv("GRPC_HOST")
@@ -51,10 +48,7 @@ func handleInstallScriptWindows(c *gin.Context) {
}
func handleUpdateScriptWindows(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
giteaHost := "gitea.hostxtra.co.uk"
script := fmt.Sprintf(
"#Requires -RunAsAdministrator\n"+
+9
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"fmt"
"io"
"net/http"
@@ -185,6 +186,10 @@ func updateStep(c *gin.Context) {
return
}
if err := services.UpdateStep(auth.InstanceID(c), c.Param("id"), s); err != nil {
if errors.Is(err, services.ErrDefaultStep) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -194,6 +199,10 @@ func updateStep(c *gin.Context) {
func deleteStep(c *gin.Context) {
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
if errors.Is(err, services.ErrDefaultStep) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+2 -9
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"sync"
@@ -80,10 +79,7 @@ type KeyGenParams struct {
}
func GetLatestAgentVersion() (string, error) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
giteaHost := "gitea.hostxtra.co.uk"
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost)
resp, err := http.Get(url)
if err != nil {
@@ -119,10 +115,7 @@ func DispatchUpdateAgent(serverID string) (string, error) {
return "", fmt.Errorf("get latest version: %w", err)
}
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
giteaHost := "gitea.hostxtra.co.uk"
cmdID := uuid.New().String()
cmd := &pb.ServerCommand{
+29
View File
@@ -2,6 +2,7 @@ package services
import (
"context"
"errors"
"fmt"
"time"
@@ -13,6 +14,24 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// ErrDefaultStep is returned when a caller tries to edit or delete a step that
// came from the image's default library. Those rows are re-seeded from disk on
// every boot, so an edit would be silently reverted and a delete would come
// back — refusing is honest about who owns them.
var ErrDefaultStep = errors.New("this step ships with Vantage and cannot be edited or deleted; duplicate it to make your own copy")
func isDefaultStep(ctx context.Context, instanceID, stepID string) (bool, error) {
var s models.WorkflowStep
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}).Decode(&s)
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if err != nil {
return false, err
}
return s.Source == "default", nil
}
func wfCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
@@ -115,6 +134,11 @@ func CreateStep(instanceID string, s models.WorkflowStep) (*models.WorkflowStep,
func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error {
ctx, cancel := wfCtx()
defer cancel()
if def, err := isDefaultStep(ctx, instanceID, stepID); err != nil {
return err
} else if def {
return ErrDefaultStep
}
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}, bson.M{"$set": bson.M{
"name": s.Name,
"description": s.Description,
@@ -131,6 +155,11 @@ func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error {
func DeleteStep(instanceID, stepID string) error {
ctx, cancel := wfCtx()
defer cancel()
if def, err := isDefaultStep(ctx, instanceID, stepID); err != nil {
return err
} else if def {
return ErrDefaultStep
}
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}); err != nil {
return err
}
+121 -120
View File
@@ -1,131 +1,132 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description:
"Vantage began as a weekend fix for a lost laptop and grew into a fleet control plane. How it is built, and what it deliberately does not do.",
title: "About",
description: "Vantage is built by HostXtra, a small UK company that manages servers for a living. Who we are, why we built it, and how we plan to keep it going.",
};
export default function AboutPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">About</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1.1rem", maxWidth: "20ch" }}>
Built for the fleet nobody was given a budget to manage.
</h1>
return (
<>
<section className="rail band band--open">
<span className="tag">About</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1.1rem", maxWidth: "20ch" }}>We built this because we needed it.</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="prose">
<p>
Vantage started as a weekend fix for a bad afternoon. A laptop was lost, and finding every server that
trusted its key meant SSHing into each one with a text editor open. The list lived in someone&apos;s head.
Two of the boxes were not on it.
</p>
<p>
The obvious tools were all heavier than the problem. A configuration management stack to write one file. A
bastion host that becomes the thing you now have to keep alive. A certificate authority with a rotation
story nobody wanted to own.
</p>
<p>
So it began with one job done properly: hold <code>authorized_keys</code> to a known state. Then the same
agent turned out to be the right place to run a deploy script, check whether a service was answering, and
open a shell when something was on fire. Each addition had to earn its place by riding the connection that
already existed.
</p>
<p>
Today it runs across homelabs, small hosting providers, and agencies who inherit client servers and need
to prove who can reach them.
</p>
</div>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="prose">
<p>
Vantage is made by HostXtra, a small company in the UK. We manage servers for a living our own and other people&apos;s and we built Vantage because we were doing that job
badly with a spreadsheet and a folder of scripts.
</p>
<p>
It started after a laptop went missing. Working out which servers that laptop could still get into meant logging into every one of them and reading a file by hand. The list
of servers lived in somebody&apos;s head, and two of them weren&apos;t on it. That took an afternoon, and it should have taken a minute.
</p>
<p>
Everything that existed to fix this was built for companies far larger than ours. We wanted one screen that answered &quot;who can get into this machine&quot; and let us
change the answer. Once that worked, the rest followed: if there&apos;s already a way to talk to every server you own, you may as well use it to run your scripts, check
things are still up, and open a terminal when something breaks at midnight.
</p>
<p>We use Vantage every day to run our own servers. When something about it is annoying, it&apos;s annoying us too, which is the main reason it keeps getting better.</p>
</div>
<div>
<span className="tag">How it is built</span>
<div className="specs">
<div className="spec">
<span className="spec__k">SERVER</span>
<div>
<h3>Go, MongoDB, Redis</h3>
<p>
One Go binary serving REST for the interface and gRPC for agents. MongoDB holds everything durable;
Redis holds sessions and nothing else.
</p>
<div>
<span className="tag">The company</span>
<div className="specs">
<div className="spec">
<span className="spec__k">WHO</span>
<div>
<h3>HostXtra</h3>
<p>A small independent team in the United Kingdom. No investors, nobody to answer to but the people paying us.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">HOW WE EARN</span>
<div>
<h3>Subscriptions, and that&apos;s all</h3>
<p>We&apos;re paid by our customers. We don&apos;t sell data, we don&apos;t sell ads, and there&apos;s no free tier being mined for anything.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">WHO YOU GET</span>
<div>
<h3>The people who wrote it</h3>
<p>Support goes to the same small group that builds the product. There is no first line, and nobody reading from a script.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">IF WE STOP</span>
<div>
<h3>You can host it yourself</h3>
<p>Every tier can run on your own hardware, and your data is exportable whenever you want it. You are not stuck with us.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="spec">
<span className="spec__k">WEB</span>
<div>
<h3>Next.js</h3>
<p>
An operations interface, not a brochure: dense tables, live log streams, and state you can read at a
glance.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">AGENT</span>
<div>
<h3>Go, Linux and Windows</h3>
<p>
A single static binary under systemd or as a Windows service. No runtime, no dependencies, no
package manager involved.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CONSOLE</span>
<div>
<h3>Guacamole</h3>
<p>
Protocol handling is a solved problem. We proxy the connection and manage the credentials around it.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
</section>
<section className="rail band">
<span className="tag">Security posture</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
The parts worth being specific about.
</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Tokens</span>
<h3>Hashed, never stored plain</h3>
<p>
Agent tokens and the secrets read token are held as SHA-256 hashes. The plaintext exists on the
agent&apos;s own disk at 0600 and nowhere else.
</p>
</article>
<article className="cap">
<span className="cap__k">At rest</span>
<h3>AES-256-GCM</h3>
<p>
Private keys, passphrases, vault secrets, identity provider secrets and console credentials are encrypted
with a key held only by your deployment.
</p>
</article>
<article className="cap">
<span className="cap__k">One-time</span>
<h3>Tokens that expire and spend</h3>
<p>
Pre-registration tokens last an hour and work once. Console session tokens are consumed the moment the
tunnel opens.
</p>
</article>
<article className="cap">
<span className="cap__k">Recorded</span>
<h3>Every mutation is audited</h3>
<p>
Assignments, revocations, runs, console sessions, secret reveals and settings changes are attributed and
kept.
</p>
</article>
</div>
</section>
</>
);
<section className="rail band">
<span className="tag">Who uses it</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>People with more servers than time.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Home labs</span>
<h3>A rack in the spare room</h3>
<p>A handful of machines you built yourself, and no interest in running enterprise software to keep track of them.</p>
</article>
<article className="cap">
<span className="cap__k">Small teams</span>
<h3>Two or three people, one estate</h3>
<p>Everyone needs access to everything, and everyone needs to know what the others changed. Usually the point where the spreadsheet stops working.</p>
</article>
<article className="cap">
<span className="cap__k">Agencies</span>
<h3>Servers you inherited</h3>
<p>Machines set up by somebody who has since left, for a client who now wants a straight answer about who can get into them.</p>
</article>
<article className="cap">
<span className="cap__k">Hosting</span>
<h3>Small providers</h3>
<p>Lots of machines, thin margins, and a need to prove access is controlled without buying something priced per seat.</p>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">How we work</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>A few things we&apos;ve decided.</h2>
<div className="specs" style={{ maxWidth: "70ch" }}>
<div className="spec">
<span className="spec__k">SMALL</span>
<div>
<h3>We say no to most things</h3>
<p>Every feature has to earn its place. A tool that manages access to your servers is one you have to be able to understand completely.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">HONEST</span>
<div>
<h3>The price is on the website</h3>
<p>All three tiers, including the largest, can be bought without speaking to anybody. There is no hidden pricing and no &quot;contact us&quot; tier.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">OPEN</span>
<div>
<h3>Bugs are in public</h3>
<p>Our issue tracker is open to read. If something is broken, you can see whether we already know about it.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CAREFUL</span>
<div>
<h3>We take security reports seriously</h3>
<p>Report something and you&apos;ll hear back from a person within three days. We would much rather you told us than didn&apos;t.</p>
</div>
</div>
</div>
</section>
</>
);
}
+5 -5
View File
@@ -3,7 +3,7 @@ import { ContactForm } from "@/components/ContactForm";
export const metadata: Metadata = {
title: "Contact",
description: "Sales questions, self-hosted licensing, security disclosures and bug reports.",
description: "Questions about pricing, licensing for self-hosted installs, security reports and bugs.",
};
const CHANNELS = [
@@ -21,13 +21,13 @@ const CHANNELS = [
},
{
title: "Bugs and feature requests",
body: "Public tracker, read by the people who write the code.",
body: "Our public issue tracker, read by the people who write the code.",
link: "git.vantage.hostxtra.co.uk/vantage",
href: "https://git.vantage.hostxtra.co.uk/vantage",
},
{
title: "Status",
body: "Control plane uptime and incident history.",
body: "Whether the hosted service is up, and a history of past incidents.",
link: "status.vantage.hostxtra.co.uk",
href: "https://status.vantage.hostxtra.co.uk",
},
@@ -37,7 +37,7 @@ export default function ContactPage() {
return (
<section className="rail band band--open">
<span className="tag">Contact</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Tell us what your fleet looks like.</h1>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Get in touch.</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="card">
@@ -45,7 +45,7 @@ export default function ContactPage() {
</div>
<div>
<p className="prose">Pick the right door and you will get a faster answer.</p>
<p className="prose">If one of these is a better fit than the form, use it you&apos;ll get an answer sooner.</p>
{CHANNELS.map((channel) => (
<div className="chan" key={channel.title}>
<h3>{channel.title}</h3>
+106
View File
@@ -1234,3 +1234,109 @@ table.cmp {
.foot__in a:hover {
color: var(--ink);
}
/* ---------- Topology diagram ---------- */
.topo {
margin: 2rem 0 0;
border: 1px solid var(--rule);
border-radius: 10px;
background: var(--panel-2);
overflow: hidden;
}
.topo__head {
display: flex;
flex-wrap: wrap;
gap: 0.9rem 1.2rem;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--rule);
background: var(--panel);
}
.topo__seg {
display: inline-flex;
border: 1px solid var(--rule);
border-radius: 6px;
overflow: hidden;
}
.topo__btn {
appearance: none;
border: 0;
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: 0.85rem;
padding: 0.42rem 0.85rem;
cursor: pointer;
}
.topo__btn + .topo__btn {
border-left: 1px solid var(--rule);
}
.topo__btn:hover {
color: var(--ink);
}
.topo__btn.is-on {
background: var(--accent);
color: var(--accent-ink);
}
.topo__btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.topo__key {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin: 0;
color: var(--ink-3);
font-family: var(--mono);
font-size: 0.72rem;
}
.topo__dot {
width: 7px;
height: 7px;
border-radius: 999px;
display: inline-block;
}
.topo__dot--out {
background: var(--up);
}
.topo__dot--in {
background: var(--accent);
margin-left: 0.8rem;
}
.topo__canvas {
display: block;
width: 100%;
height: 380px;
}
.topo__note {
margin: 0;
padding: 0.8rem 0.95rem;
border-top: 1px solid var(--rule);
background: var(--panel);
color: var(--ink-2);
font-size: 0.88rem;
max-width: 78ch;
}
@media (max-width: 660px) {
.topo__canvas {
height: 460px;
}
}
+5 -5
View File
@@ -7,15 +7,15 @@ import { ThemeScript } from "@/components/ThemeScript";
export const metadata: Metadata = {
metadataBase: new URL("https://vantage.hostxtra.co.uk"),
title: {
default: "Vantage one control plane for the whole fleet",
template: "%s Vantage",
default: "Vantage: one place to manage every server you look after",
template: "%s · Vantage",
},
description: "Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.",
description: "Manage access to your servers, run your scripts, check your services are up, store your passwords and open a terminal in the browser. Host it yourself or let us host it.",
openGraph: {
type: "website",
siteName: "Vantage",
title: "Vantage one control plane for the whole fleet",
description: "Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.",
title: "Vantage: one place to manage every server you look after",
description: "Server access, scripts, uptime checks, stored passwords and a terminal in the browser. Host it yourself or let us host it.",
},
icons: { icon: "/images/vantage_logo.svg" },
};
+53 -50
View File
@@ -6,21 +6,21 @@ export default function OverviewPage() {
<>
<div className="heroband">
<section className="rail hero">
<span className="tag">Self-hosted fleet control plane</span>
<h1>Your servers, under one pane of glass you actually own.</h1>
<span className="tag">Server management you can host yourself</span>
<h1>One place to manage every server you look after.</h1>
<p className="lede">
Vantage holds SSH keys, runs scripts, watches services, stores secrets and opens consoles across every machine you manage. One agent per server, outbound connections only, all
state in your own database.
Vantage keeps track of who has access to which server, runs your scripts, checks your services are still up, stores your passwords and gives you a terminal in the browser. You
install one small program on each server, and you can run the whole thing on your own hardware.
</p>
<div className="hero__acts">
<Link className="btn btn--solid" href="/start">
Create your instance
Get started free
</Link>
<Link className="btn btn--line" href="/platform">
What it does
</Link>
</div>
<p className="hero__foot">Free for 3 servers · Linux and Windows agents · Self-host the whole stack</p>
<p className="hero__foot">Free for 3 servers · Linux and Windows · Hosted by us or by you</p>
</section>
</div>
@@ -28,25 +28,25 @@ export default function OverviewPage() {
<section className="rail band band--open">
<span className="tag">The problem</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>Six tools, six sources of truth, one afternoon lost.</h2>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>Nothing knows about anything else.</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
Most small fleets end up with keys in a spreadsheet, scripts in someone&apos;s home directory, uptime checks in a separate service, secrets in a chat thread, and no record of
who ran what. None of those systems know about each other, so every question who can reach this box, what ran on it last, is it even up gets answered by hand.
Access ends up in a spreadsheet. Scripts end up in somebody&apos;s home directory. Uptime checks live in a separate service, passwords get pasted into a chat thread, and nobody
writes down who ran what. So when you need to know who can get into a particular box, or what was done to it last week, you go and work it out by hand.
</p>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">ONE AGENT</span>
<div>
<h3>Everything rides one connection</h3>
<p>Keys, steps, checks and inventory all travel over the same outbound link. Installing a second thing is not the answer.</p>
<h3>One thing to install</h3>
<p>Access, scripts, uptime checks and hardware stats all come down the same connection. You don&apos;t install a second thing to get the second feature.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ONE RECORD</span>
<div>
<h3>Every change is an audit event</h3>
<p>Assignments, revocations, runs, console sessions and settings changes all land in the same log, attributed to a person.</p>
<h3>Everything is written down</h3>
<p>Who was given access and who took it away, who ran which script, who opened a session. It all goes in the same log with a name against it.</p>
</div>
</div>
</div>
@@ -55,73 +55,76 @@ export default function OverviewPage() {
<section className="rail band">
<span className="tag">Capabilities</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>One control plane, six jobs.</h2>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>What it does.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Access</span>
<h3>SSH keys</h3>
<p>
Assign public keys per server and revoke them softly. The agent diffs desired state against the file and rewrites <code>authorized_keys</code> atomically.
Choose which servers a key can reach, and take that access away again when someone leaves. Each server is kept matching what you set, without you logging in to change it.
</p>
<ul>
<li>fingerprint deduplication</li>
<li>agent-side keypair generation</li>
<li>revocation history preserved</li>
<li>spots the same key added twice</li>
<li>can create a key on the server itself</li>
<li>keeps a record of access you removed</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Execution</span>
<span className="cap__k">Scripts</span>
<h3>Workflows</h3>
<p>A library of bash and PowerShell steps with declared inputs, outputs and secret references, composed into workflows that target a set of servers.</p>
<p>Keep your shell and PowerShell scripts in one library instead of scattered across machines, then run them in order across a group of servers.</p>
<ul>
<li>live streamed step logs</li>
<li>stop, continue or retry on failure</li>
<li>values passed between steps</li>
<li>watch the output as it happens</li>
<li>stop, carry on or retry when a step fails</li>
<li>pass values from one step to the next</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Uptime</span>
<h3>Monitors</h3>
<p>HTTP, TCP, ICMP and TLS checks, run either from the control plane or from an agent inside the target network.</p>
<p>
Check that a site, a port, a machine or a certificate is still answering. Checks can run from us, or from an agent inside your own network for things the internet
can&apos;t see.
</p>
<ul>
<li>incidents and uptime history</li>
<li>certificate expiry warnings</li>
<li>alerts to five channel types</li>
<li>a history of outages and uptime</li>
<li>warnings before a certificate expires</li>
<li>alerts by email, Slack, Discord or Telegram</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Secrets</span>
<span className="cap__k">Passwords</span>
<h3>Vault</h3>
<p>Grouped key/value secrets encrypted with AES-256-GCM, injected into workflow steps at execution and never written to logs.</p>
<p>Keep passwords, tokens and licence keys in one encrypted place. Your scripts are handed what they need as they run, and it never shows up in the logs afterwards.</p>
<ul>
<li>read token for External Secrets Operator</li>
<li>rotatable, hashed at rest</li>
<li>reveal is an audited action</li>
<li>grouped by the thing they belong to</li>
<li>encrypted with a key only you hold</li>
<li>looking at one is recorded</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Access</span>
<h3>Browser console</h3>
<p>Open an SSH, RDP or VNC session in the browser. SSH authenticates with a stored key, and every session is recorded in the audit log.</p>
<p>Open a terminal or a remote desktop from the browser. Nothing to install on the machine you&apos;re sitting at, and every session goes in the log.</p>
<ul>
<li>one-time session tokens</li>
<li>credentials consumed on connect</li>
<li>no client software</li>
<li>links that only work once</li>
<li>passwords used once, then discarded</li>
<li>works from a borrowed laptop</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Health</span>
<h3>Inventory and updates</h3>
<p>CPU, memory, swap, disks and kernel reported continuously, alongside pending OS package updates you can apply from the interface.</p>
<h3>Hardware and updates</h3>
<p>See processor, memory, disk and operating system for every server, along with the updates waiting to be installed and install them without logging in.</p>
<ul>
<li>metrics every 30 seconds</li>
<li>one-click package updates</li>
<li>agents update themselves</li>
<li>refreshed every 30 seconds</li>
<li>updates applied from the same screen</li>
<li>agents keep themselves up to date</li>
</ul>
</article>
</div>
@@ -129,27 +132,27 @@ export default function OverviewPage() {
<section className="rail band">
<span className="tag">Getting started</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "20ch" }}>Four steps, and the first three take a minute.</h2>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "20ch" }}>Getting your first server in.</h2>
<div className="flow">
<div className="flow__c">
<span className="flow__n">FIRST</span>
<h3>Create an instance</h3>
<p>You become the owner. Everything inside is invisible to every other instance.</p>
<h3>Set up your account</h3>
<p>You&apos;re the owner. Nobody outside your account can see anything in it.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Add a server</h3>
<p>Copy the install one-liner. It expires in an hour and works exactly once.</p>
<p>Copy the install command we show you and paste it into the server. It works once, then expires.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Assign a key</h3>
<p>Paste a public key and tick the servers. It lands inside 30 seconds.</p>
<h3>Give yourself access</h3>
<p>Paste your public key and tick the servers it should reach. It arrives within 30 seconds.</p>
</div>
<div className="flow__c">
<span className="flow__n">AFTER</span>
<h3>Build from there</h3>
<p>Add checks, write a step, invite the team, connect your identity provider.</p>
<h3>Add the rest as you need it</h3>
<p>Set up an uptime check, save a script, invite the people you work with.</p>
</div>
</div>
@@ -168,7 +171,7 @@ export default function OverviewPage() {
<div className="card card--cta">
<div style={{ maxWidth: "48ch" }}>
<h2 style={{ fontSize: "var(--s-1)" }}>Three servers, free, no card.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>Create an instance, install one agent, and watch a key land on a real box.</p>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>Set up an account, add one server, and see it working before you decide anything.</p>
</div>
<Link className="btn btn--solid" href="/start">
Get started
+67 -65
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import { TopologyDiagram } from "@/components/TopologyDiagram";
export const metadata: Metadata = {
title: "Platform",
description: "How Vantage fits together: a control plane you run, one agent per server, and a single outbound connection between them.",
description: "How Vantage fits together: the part you run, a small program on each server, and one connection between them that the server opens itself.",
};
export default function PlatformPage() {
@@ -11,43 +12,45 @@ export default function PlatformPage() {
<section className="rail band band--open">
<span className="tag">Platform</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "19ch" }}>How the pieces fit together.</h1>
<p className="lede">Three moving parts: a control plane you run, an agent on each server, and one outbound connection between them.</p>
<p className="lede">There are only three parts: Vantage itself, a small program on each of your servers, and one connection between them that your server opens.</p>
<TopologyDiagram />
<div className="split" style={{ marginTop: "3rem" }}>
<div>
<h2 style={{ fontSize: "var(--s-2)", maxWidth: "18ch" }}>The agent never listens.</h2>
<h2 style={{ fontSize: "var(--s-2)", maxWidth: "18ch" }}>Your servers call us, not the other way round.</h2>
<div className="prose" style={{ marginTop: "1rem" }}>
<p>
Every agent dials out to the control plane over gRPC with TLS. Nothing needs an inbound port, nothing needs a static address, and a machine behind NAT is no different
from one with a public IP.
Each server reaches out to Vantage over an encrypted connection it opens itself. You don&apos;t open a port in your firewall, you don&apos;t need a fixed address, and a
machine sitting behind a home router works exactly like one in a data centre.
</p>
<p>
Key state is polled on a 30-second interval, because 30 seconds is fine for access control and polling is simple to reason about. Everything that should not wait
running a step, opening a console, applying updates is pushed down a bidirectional command stream the agent holds open.
Once that connection is open it stays open, so anything you ask for from the browser run this script, open a session, install these updates happens straight away rather
than on a timer.
</p>
</div>
</div>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">POLL</span>
<span className="spec__k">CHECKING</span>
<div>
<h3>SyncKeys, every 30s</h3>
<p>The desired key set for this server. Unchanged state means no disk write at all.</p>
<h3>Access, every 30 seconds</h3>
<p>The server asks who should be able to reach it. If nothing has changed, nothing is touched.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">PUSH</span>
<span className="spec__k">ASKING</span>
<div>
<h3>Command stream</h3>
<p>Generate a key, run a step, apply updates, update the agent, clean up a workspace.</p>
<h3>Anything you press</h3>
<p>Run a script, open a session, install updates, create a key. Sent down the connection as you ask for it.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REPORT</span>
<span className="spec__k">REPORTING</span>
<div>
<h3>Inventory and checks</h3>
<p>Metrics every 30 seconds, a full hardware snapshot every 15 minutes, and monitor results as they complete.</p>
<h3>Health and uptime</h3>
<p>Load and free space every 30 seconds, full hardware details every 15 minutes, uptime checks as they finish.</p>
</div>
</div>
</div>
@@ -55,91 +58,90 @@ export default function PlatformPage() {
</section>
<section className="rail band">
<span className="tag">Write path</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>The file is never half-written.</h2>
<div className="split split--even" style={{ marginTop: "2rem" }}>
<span className="tag">Changing access</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>Careful about the file it owns.</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
The agent computes the desired <code>authorized_keys</code> content, compares it to what is on disk, and stops there if nothing changed. When it does need to write, it writes a
temporary file in the same directory and renames it over the real one. A machine that loses power mid-write keeps the file it had.
The only thing Vantage changes on your server is the list of keys allowed to log in. It compares that list against what you have set and leaves it alone when they already
match, so most of the time it writes nothing at all. When it does need to make a change, it prepares the new list first and swaps it in one go a server that loses power halfway
through keeps the list it already had, rather than ending up with half of one.
</p>
<pre className="code">
<i>// agent poll, simplified</i>
{"\n"}
desired := client.SyncKeys(serverID, token){"\n"}
current := keys.ReadAuthorizedKeys(){"\n\n"}
<b>if</b> !keys.StateChanged(current, desired) {"{"}
{"\n "}
<i>// nothing to do</i>
{"\n "}
<b>return</b> nil{"\n"}
{"}"}
{"\n\n"}
keys.WriteAuthorizedKeys(desired){"\n"}
<i>// write .tmp, os.Rename(), chmod 0600</i>
</pre>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">SCOPE</span>
<div>
<h3>One file, nothing else</h3>
<p>It doesn&apos;t manage your packages, your configuration or your users. Anything else that happens is a script you wrote and asked it to run.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REMOVING</span>
<div>
<h3>Taking access away</h3>
<p>Untick a server and the key is gone from it within 30 seconds. We keep the record that it was there, and who removed it.</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Tenancy and identity</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Instances are the boundary.</h2>
<span className="tag">People and access</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Who can see what.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Isolation</span>
<h3>Scoped at the query</h3>
<p>
Every server, key, workflow, monitor and secret belongs to an instance, and every lookup is filtered by it. Uniqueness constraints are enforced by the database, not by
application logic.
</p>
<span className="cap__k">Separation</span>
<h3>Your account is its own world</h3>
<p>Servers, keys, scripts, checks and passwords all belong to one account. There is no shared view, and no setting that could accidentally expose one account to another.</p>
</article>
<article className="cap">
<span className="cap__k">Roles</span>
<h3>Owner, admin, member</h3>
<p>Members operate the fleet. Admins and owners manage people, identity settings and the secrets read token.</p>
<p>Members get on with the day-to-day work. Admins and owners are the ones who add and remove people and change how the account is set up.</p>
</article>
<article className="cap">
<span className="cap__k">Identity</span>
<h3>Local or OIDC, per instance</h3>
<p>Sign in with email and password, or connect your own provider. Each instance configures its own issuer and client.</p>
<span className="cap__k">Signing in</span>
<h3>Email, or your own login</h3>
<p>Email and password works out of the box. If your organisation already has a single sign-on provider, you can point Vantage at it instead.</p>
</article>
<article className="cap">
<span className="cap__k">Sessions</span>
<h3>Server-side, 24 hours</h3>
<p>Cookies carry an opaque identifier and nothing else. Session bodies live in Redis, so losing it signs everyone out and costs no durable data.</p>
<span className="cap__k">Storage</span>
<h3>Encrypted, and yours</h3>
<p>Private keys, passwords and saved credentials are encrypted before they are stored. Host it yourself and the key that unlocks them is one only you have.</p>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">What we do not build</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>The scope is the feature.</h2>
<span className="tag">What it deliberately doesn&apos;t do</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Things we left out on purpose.</h2>
<div className="specs" style={{ maxWidth: "70ch" }}>
<div className="spec">
<span className="spec__k">NOT A PROXY</span>
<span className="spec__k">NOT IN THE WAY</span>
<div>
<h3>We are never in the SSH path</h3>
<p>Vantage assigns keys; your client connects straight to the box. If our control plane is down, your SSH still works.</p>
<h3>Your connections don&apos;t go through us</h3>
<p>Vantage decides who is allowed in; when you log in you go straight to the server. If Vantage is down for any reason, you can still get to your machines.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO CUSTODY</span>
<span className="spec__k">NOT A KEYRING</span>
<div>
<h3>Private keys stay put by default</h3>
<p>Keys generated on a server stay on it unless you explicitly upload the private half, and anything stored is encrypted with a key only your deployment holds.</p>
<h3>We&apos;d rather not hold your private keys</h3>
<p>A key created on a server stays there unless you choose to upload it, and we only ever need the public half to give someone access.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO PER-USER</span>
<span className="spec__k">NOT PER-PERSON</span>
<div>
<h3>Root, not every account</h3>
<p>Vantage manages one file per server. Per-user key management is a different product with a different failure mode.</p>
<h3>Access to the server, not to accounts on it</h3>
<p>Vantage manages administrator access to each machine. Handing individual people their own separate logins on every box is a different job, and a bigger one.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO PLUGINS</span>
<span className="spec__k">NO ADD-ONS</span>
<div>
<h3>An agent you can read in an evening</h3>
<p>A few thousand lines of Go with no extension system. Auditability beats extensibility on a binary that runs as root.</p>
<h3>Nothing to extend, on purpose</h3>
<p>The program on your servers is small and has no plugin system. It runs with full privileges, so we would rather it be something you could sit down and read.</p>
</div>
</div>
</div>
+37 -102
View File
@@ -3,8 +3,7 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing",
description:
"Three tiers, cloud or self-hosted, priced per managed server. Free for 3 servers. Professional from £19 a month, Enterprise from £49 a month, £5 per extra server.",
description: "Three tiers, cloud or self-hosted, priced per managed server. Free for 3 servers. Professional from £19 a month, Enterprise from £49 a month, £5 per extra server.",
};
/* Prices and allowances are the shipped catalogue, not marketing rounding:
@@ -12,7 +11,7 @@ export const metadata: Metadata = {
* catalogue. Annual is ten times monthly everywhere, which is the "two months
* free" the portal's own term picker states. */
const COMPARISON: [string, string, string, string][] = [
["Managed servers", "3", "3, then £5 each", "10, then £5 each"],
["Managed servers", "3", "5, then £2.50 each", "10, then £2.50 each"],
["People", "Unlimited", "Unlimited", "Unlimited"],
["SSH key assignment", "Yes", "Yes", "Yes"],
["Workflows and step library", "Yes", "Yes", "Yes"],
@@ -41,28 +40,20 @@ export default function PricingPage() {
Pick a tier, then pay per server.
</h1>
<p className="lede">
People are free. Keys, workflows, monitors and secrets are free.
You pay for a tier and for the servers running an agent, because
that is the only number that grows with you. Every tier runs
either in our cloud or on your own hardware, for the same money.
You pay for the number of servers you manage, and nothing else. Adding people costs nothing, and neither does adding keys, scripts, uptime checks or stored passwords. Every tier costs
the same whether we host it or you do.
</p>
<div className="plans">
<div className="plan">
<div>
<div className="plan__n">Free</div>
<p className="plan__d">
A homelab, a couple of VPSes, and the keys on your
own laptop.
</p>
<p className="plan__d">For a home lab or a couple of servers you look after on your own.</p>
</div>
<div className="plan__p">
£0 <span>3 servers</span>
</div>
<p className="plan__t">
Cloud or self-hosted. Renewed once a year from the
portal, one free instance per deployment.
</p>
<p className="plan__t">Cloud or self-hosted. Renewed once a year from the portal, one free instance per deployment.</p>
<ul>
<li>3 servers and 3 monitors</li>
<li>Keys, workflows and the step library</li>
@@ -78,18 +69,12 @@ export default function PricingPage() {
<div className="plan plan--pick">
<div>
<div className="plan__n">Professional</div>
<p className="plan__d">
Real infrastructure, and more than one person
holding the keys.
</p>
<p className="plan__d">For servers people rely on, looked after by more than one person.</p>
</div>
<div className="plan__p">
£19 <span>/ month, 3 servers</span>
</div>
<p className="plan__t">
£5 per extra server. £190 a year saves two months.
Self-hosted is £190 a year.
</p>
<p className="plan__t">£2.50 per extra server. £190 a year saves two months. Self-hosted is £190 a year.</p>
<ul>
<li>3 servers included, add as many as you like</li>
<li>Unlimited monitors, secrets and channels</li>
@@ -105,18 +90,12 @@ export default function PricingPage() {
<div className="plan">
<div>
<div className="plan__n">Enterprise</div>
<p className="plan__d">
A fleet, a compliance question, and someone who
wants a phone number.
</p>
<p className="plan__d">For larger estates, and for anyone who has to answer to an auditor.</p>
</div>
<div className="plan__p">
£49 <span>/ month, 10 servers</span>
</div>
<p className="plan__t">
£5 per extra server. £490 a year saves two months.
Self-hosted is £490 a year.
</p>
<p className="plan__t">£2.50 per extra server. £490 a year saves two months. Self-hosted is £490 a year.</p>
<ul>
<li>10 servers included, add as many as you like</li>
<li>Everything in Professional</li>
@@ -130,44 +109,26 @@ export default function PricingPage() {
</div>
</div>
<div
className="specs"
style={{ marginTop: "2.6rem", maxWidth: "66ch" }}
>
<span className="tag">
Add-ons, on Professional and Enterprise
</span>
<div className="specs" style={{ marginTop: "2.6rem", maxWidth: "66ch" }}>
<span className="tag">Add-ons, on Professional and Enterprise</span>
<div className="spec">
<span className="spec__k">£5 / MO</span>
<div>
<h3>Browser console</h3>
<p>
SSH, RDP and VNC sessions in the browser, audited
like everything else. £50 a year.
</p>
<p>SSH, RDP and VNC sessions in the browser, audited like everything else. £50 a year.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">£9 / MO</span>
<div>
<h3>Single sign-on</h3>
<p>
Connect your own OIDC provider to an instance. £90 a
year.
</p>
<p>Connect your own OIDC provider to an instance. £90 a year.</p>
</div>
</div>
</div>
<p className="scroll__hint">
Scroll the table sideways for all three tiers
</p>
<div
className="scroll"
tabIndex={0}
role="region"
aria-label="Plan comparison"
>
<p className="scroll__hint">Scroll the table sideways for all three tiers</p>
<div className="scroll" tabIndex={0} role="region" aria-label="Plan comparison">
<table className="cmp">
<thead>
<tr>
@@ -178,16 +139,14 @@ export default function PricingPage() {
</tr>
</thead>
<tbody>
{COMPARISON.map(
([capability, free, professional, enterprise]) => (
<tr key={capability}>
<td>{capability}</td>
<td>{free}</td>
<td>{professional}</td>
<td>{enterprise}</td>
</tr>
),
)}
{COMPARISON.map(([capability, free, professional, enterprise]) => (
<tr key={capability}>
<td>{capability}</td>
<td>{free}</td>
<td>{professional}</td>
<td>{enterprise}</td>
</tr>
))}
</tbody>
</table>
</div>
@@ -199,44 +158,30 @@ export default function PricingPage() {
<span className="spec__k">COUNTING</span>
<div>
<h3>What counts as a server</h3>
<p>
One running agent, one server. You choose the
cap rather than being metered on what you ran,
so the invoice is the same number you set.
</p>
<p>Anything with Vantage installed on it counts as one. You set the limit yourself rather than being metered, so the bill is always the number you chose.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ADDING</span>
<div>
<h3>Going up mid-term</h3>
<p>
Raise the server count and the new cap applies
straight away, prorated for the rest of the
period. Your renewal date does not move.
</p>
<p>Raise the server count and the new cap applies straight away, prorated for the rest of the period. Your renewal date does not move.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REDUCING</span>
<div>
<h3>Going down</h3>
<p>
A reduction takes effect at your next renewal.
You keep what you have already paid for until
then, and we show you the date it changes.
</p>
<p>A reduction takes effect at your next renewal. You keep what you have already paid for until then, and we show you the date it changes.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">LIMITS</span>
<div>
<h3>Reaching the cap</h3>
<h3>Reaching your limit</h3>
<p>
Nothing is deleted. An agent beyond the cap
still registers and heartbeats, but stops
syncing keys until you raise the cap or remove a
server.
Nothing gets deleted. A server past the limit still checks in and still shows up in your list, but access changes stop reaching it until you raise the limit or remove
one.
</p>
</div>
</div>
@@ -245,24 +190,18 @@ export default function PricingPage() {
<div>
<h3>What happens when you stop paying</h3>
<p>
Your licence runs to the end of the period you
paid for, then the instance goes read-only.
Monitors keep checking, alerts keep firing and
agents keep their keys you just cannot change
anything until you renew.
You keep everything until the end of the period you paid for. After that it becomes read-only: your uptime checks carry on running, your alerts still arrive and your
servers keep the access they have. You just can&apos;t change anything until you renew.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">SELF-HOSTED</span>
<div>
<h3>Why self-hosted is annual only</h3>
<h3>Why self-hosted is yearly only</h3>
<p>
A self-hosted licence is a signed file your
install checks offline, with no phone home. That
means we cannot withdraw one early, so the term
is the limit and we would rather bound it at a
year than sell it twelve times over.
When you host it yourself, your licence is a file your installation checks on its own. It never contacts us, which is the point but it also means we can&apos;t switch
one off partway through, so we sell it a year at a time.
</p>
</div>
</div>
@@ -271,10 +210,7 @@ export default function PricingPage() {
<div>
<h3>Leaving</h3>
<p>
Export every server, key, workflow and secret
group as JSON whenever you like. Agents keep
their last synced state on disk, so nobody is
locked out mid-migration.
You can export your servers, keys, scripts and stored passwords at any time. Your servers keep the access they already have, so nobody gets locked out while you move.
</p>
</div>
</div>
@@ -282,8 +218,7 @@ export default function PricingPage() {
</div>
<p className="prose" style={{ marginTop: "2.4rem" }}>
Prices exclude VAT, which is calculated at checkout. Billing is
handled by Paddle as merchant of record.{" "}
Prices exclude VAT, which is calculated at checkout. Billing is handled by Paddle as merchant of record.{" "}
<Link href="/contact" style={{ color: "var(--accent)" }}>
Ask us anything
</Link>{" "}
+9 -9
View File
@@ -3,7 +3,7 @@ import { AccountForm } from "@/components/AccountForm";
export const metadata: Metadata = {
title: "Vantage Cloud",
description: "Your account is where instances, licences and billing live. Free for three servers, hosted or self-hosted.",
description: "Set up an account, confirm your email, and get a free Vantage for three servers. Hosted by us, or installed on your own hardware.",
};
export default function StartPage() {
@@ -14,7 +14,7 @@ export default function StartPage() {
<span className="tag">Vantage Cloud</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Create your account.</h1>
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
Your account is where instances, licences and billing live. Confirm your email and you can create a free instance straight away three servers, hosted by us.
This is where your billing and your Vantage sites live. Confirm your email address and you can set one up right away free, for three servers, hosted by us.
</p>
<div className="card" style={{ marginTop: "1.9rem" }}>
@@ -29,34 +29,34 @@ export default function StartPage() {
<span className="spec__k">FIRST</span>
<div>
<h3>Confirm your email</h3>
<p>We send a link that works once. Your account is created when you open it, not before.</p>
<p>We send you a link that works once. Nothing is created until you open it.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Create your instance</h3>
<p>One click in the portal. It gets its own subdomain and a free licence, and you are its owner.</p>
<h3>Set up your Vantage</h3>
<p>One click. It gets its own web address, and you&apos;re the owner of it.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Add a key and a server</h3>
<p>Paste your public key, then run the install command as root. It expires in an hour and works once.</p>
<p>Paste in your public key, then run the install command on the server. It works once and expires after an hour.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">THEN</span>
<div>
<h3>Watch it register</h3>
<p>The server moves from pending to active on first sync, usually inside 30 seconds.</p>
<h3>Watch it appear</h3>
<p>The server turns from pending to active as soon as it checks in, usually within 30 seconds.</p>
</div>
</div>
</div>
<pre className="code" style={{ marginTop: "1.8rem" }}>
<b>curl</b> -fsSL https://vantage.hostxtra.co.uk/install | \{"\n"}
<b>curl</b> -fsSL https://vantage.mydomain.com/install | \{"\n"}
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
</pre>
</div>
+2 -2
View File
@@ -29,10 +29,10 @@ export function AccountForm() {
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We sent a confirmation link. Open it and your Vantage account is ready then you can create your first instance from the portal. The link works once and expires in 24 hours.
We&apos;ve sent you a confirmation link. Open it and your account is ready to use. The link works once, and expires after 24 hours.
</p>
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
Nothing exists until you confirm if the email does not arrive, start again or contact support@hostxtra.co.uk.
Nothing is created until you confirm if the email doesn&apos;t arrive, start again or email support@hostxtra.co.uk.
</p>
</div>
);
+1 -1
View File
@@ -5,7 +5,7 @@ export function Footer() {
return (
<footer className="foot">
<div className="rail foot__in">
<p>Vantage self-hosted fleet control for people who own their servers.</p>
<p>Vantage server management for people who own their servers.</p>
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href}>
{link.label}

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