From f10e61cde426aa5c7376949026d7589b5ec75872 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 28 Jul 2026 12:46:28 +0100 Subject: [PATCH] feat: Updated marketing site --- adminsite/app/(customer)/billing/page.tsx | 45 +--- .../app/(customer)/instances/link/page.tsx | 2 +- adminsite/app/(customer)/layout.tsx | 10 +- adminsite/app/(customer)/page.tsx | 82 ++---- .../app/(customer)/purchase/PurchaseForm.tsx | 14 +- .../app/(staff)/staff/accounts/[id]/page.tsx | 26 +- .../staff/instances/[id]/IssuePanel.tsx | 40 +-- .../app/(staff)/staff/instances/[id]/page.tsx | 67 +---- adminsite/app/(staff)/staff/page.tsx | 35 +-- adminsite/app/(staff)/staff/plans/page.tsx | 75 ++---- adminsite/components/AppBar.tsx | 24 +- adminsite/components/Button.tsx | 20 +- adminsite/components/ConfirmPlanChange.tsx | 29 +-- adminsite/components/InstanceRecord.tsx | 2 +- adminsite/components/Ledger.tsx | 37 +-- adminsite/components/LicenceDelivery.tsx | 31 +-- adminsite/components/MembersPanel.tsx | 52 +--- adminsite/components/NotConnected.tsx | 13 +- adminsite/components/PageFrame.tsx | 32 +-- adminsite/components/PageHeader.tsx | 15 +- adminsite/components/RelinkPanel.tsx | 39 +-- adminsite/lib/theme.tsx | 6 +- site/app/about/page.tsx | 241 +++++++++--------- site/app/contact/page.tsx | 10 +- site/app/layout.tsx | 10 +- site/app/page.tsx | 103 ++++---- site/app/platform/page.tsx | 129 +++++----- site/app/pricing/page.tsx | 37 +-- site/app/start/page.tsx | 18 +- site/components/AccountForm.tsx | 4 +- site/components/Footer.tsx | 2 +- web/app/(app)/settings/license/page.tsx | 110 ++++---- web/app/layout.tsx | 30 +-- web/app/setup/page.tsx | 12 +- web/components/NetworkBackground.tsx | 12 +- web/components/Sidebar.tsx | 22 +- web/components/settings/SectionCard.tsx | 2 +- web/components/ui/Badge.tsx | 44 ++-- web/components/ui/Table.tsx | 167 ++++++------ 39 files changed, 599 insertions(+), 1050 deletions(-) diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx index 440b396..33b92a0 100644 --- a/adminsite/app/(customer)/billing/page.tsx +++ b/adminsite/app/(customer)/billing/page.tsx @@ -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 (
-

- Change a card, download an invoice or cancel from the billing - portal. It covers every subscription on this account. -

+

Change a card, download an invoice or cancel from the billing portal. It covers every subscription on this account.

-

- Anything else, email support. -

- +

Anything else, email support.

+
support@hostxtra.co.uk
@@ -70,10 +57,7 @@ export default function BillingPage() { } > {rows.length === 0 ? ( -

- You have no subscriptions. Cloud instances and self-hosted licences are - both bought from the pricing page. -

+

You have no subscriptions. Cloud instances and self-hosted licences are both bought from the pricing page.

) : (
@@ -88,21 +72,12 @@ export default function BillingPage() { {rows.map((s) => ( - - + + - + ))} diff --git a/adminsite/app/(customer)/instances/link/page.tsx b/adminsite/app/(customer)/instances/link/page.tsx index 4e1889f..1945e13 100644 --- a/adminsite/app/(customer)/instances/link/page.tsx +++ b/adminsite/app/(customer)/instances/link/page.tsx @@ -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(() => { diff --git a/adminsite/app/(customer)/layout.tsx b/adminsite/app/(customer)/layout.tsx index 2bed622..cf01401 100644 --- a/adminsite/app/(customer)/layout.tsx +++ b/adminsite/app/(customer)/layout.tsx @@ -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 ( - - {data.account.name} - - ); + return {data.account.name}; } export default function CustomerLayout({ children }: { children: React.ReactNode }) { diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx index ad6b21d..311e36b 100644 --- a/adminsite/app/(customer)/page.tsx +++ b/adminsite/app/(customer)/page.tsx @@ -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 (
0 ? ( - Buy a plan - ) : undefined - } + actions={live.length > 0 ? Buy a plan : 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 ? ( - - ) : undefined - } + status={attention.length === 0 && live.length > 0 ? : undefined} /> {live.length === 0 ? (

No instances yet

- 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.

Buy a plan @@ -104,16 +88,9 @@ export default function OverviewPage() {
    {attention.map((a) => ( -
  • +
  • {a.text} - {a.note && ( - - {a.note} - - )} + {a.note && {a.note}}
  • ))}
@@ -123,27 +100,18 @@ export default function OverviewPage() {
    {(people.data ?? []).slice(0, 5).map((p) => ( -
  • +
  • {p.email} - - {p.account_role} - + {p.account_role}
  • ))}
{pending > 0 && (

- {pending} {pending === 1 ? "invitation" : "invitations"} not - accepted yet + {pending} {pending === 1 ? "invitation" : "invitations"} not accepted yet

)} - + Manage people
@@ -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. */} @@ -165,23 +133,14 @@ export default function OverviewPage() { }, ]} /> - + Billing history -

- Get a licence for your own install — free or paid — from the - purchase page. It keeps its own users. -

- +

Get a licence for your own install free or paid from the purchase page. It keeps its own users.

+ Get a licence
@@ -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)} /> ); })} diff --git a/adminsite/app/(customer)/purchase/PurchaseForm.tsx b/adminsite/app/(customer)/purchase/PurchaseForm.tsx index 47471e5..899ed9a 100644 --- a/adminsite/app/(customer)/purchase/PurchaseForm.tsx +++ b/adminsite/app/(customer)/purchase/PurchaseForm.tsx @@ -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() {

- 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.

@@ -423,7 +419,7 @@ export function PurchaseForm() { )} {pending?.deployment === "cloud" && (
-

Your instance is being set up. Its licence appears the moment payment clears — no further steps.

+

Your instance is being set up. Its licence appears the moment payment clears no further steps.

Go to your instance @@ -554,7 +550,7 @@ function TierCard({ return ( {featureLabel(key)} - {st === "included" ? " — included" : st === "addon" ? " — add-on" : " — not available"} + {st === "included" ? " included" : st === "addon" ? " add-on" : " not available"} ); })} diff --git a/adminsite/app/(staff)/staff/accounts/[id]/page.tsx b/adminsite/app/(staff)/staff/accounts/[id]/page.tsx index a00cd67..9877007 100644 --- a/adminsite/app/(staff)/staff/accounts/[id]/page.tsx +++ b/adminsite/app/(staff)/staff/accounts/[id]/page.tsx @@ -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() {
    {data.instances.map((i) => (
  • - + {i.name || i.instance_id} @@ -71,19 +66,10 @@ export default function AccountDetailPage() { {data.users.map((u) => (
  • {u.email} - - {u.verified_at - ? `verified ${formatDate(u.verified_at)}` - : "not verified"} - + {u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"}
  • ))} - {data.users.length === 0 && ( -
  • - None — this is a cloud account, so its people sign in with their - control-plane details. -
  • - )} + {data.users.length === 0 &&
  • None this is a cloud account, so its people sign in with their control-plane details.
  • }
@@ -94,9 +80,7 @@ export default function AccountDetailPage() { {e.action} · {e.actor} - - {formatDate(e.created_at)} - + {formatDate(e.created_at)} ))} {data.audit.length === 0 &&
  • Nothing yet.
  • } diff --git a/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx b/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx index 60b7205..49c4a37 100644 --- a/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx +++ b/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx @@ -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("professional"); const [term, setTerm] = useState("annual"); @@ -35,28 +31,16 @@ export function IssuePanel({
    - setNewId(e.target.value)} - hint="Staff relinks are not capped — the customer cap exists to put you in the loop." - /> -
    diff --git a/adminsite/app/(staff)/staff/instances/[id]/page.tsx b/adminsite/app/(staff)/staff/instances/[id]/page.tsx index 3dbb85e..e946ec1 100644 --- a/adminsite/app/(staff)/staff/instances/[id]/page.tsx +++ b/adminsite/app/(staff)/staff/instances/[id]/page.tsx @@ -14,7 +14,7 @@ import { IssuePanel } from "./IssuePanel"; const INJECTION: Record = { 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={ <> - + {data.account.name || data.account.account_id} {" "} · {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`} } - 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 && ( -

    {inj.label}

    - )} + {data.injection.applicable && inj &&

    {inj.label}

    }
    @@ -76,10 +65,7 @@ export default function StaffInstancePage() {
    - +
    ); } @@ -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

    Entitlement

    - What this instance is allowed. A licence is signed from{" "} - granted, never from desired. + What this instance is allowed. A licence is signed from granted, never from desired.

    {ent && data?.pending && (

    - 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" })}` : ""}.

    )} - +
    -
    -

    - Granting takes effect on the next licence issued. It does not issue - one. -

    - {save.error && ( -

    - {String((save.error as Error).message)} -

    - )} +

    Granting takes effect on the next licence issued. It does not issue one.

    + {save.error &&

    {String((save.error as Error).message)}

    } ); } diff --git a/adminsite/app/(staff)/staff/page.tsx b/adminsite/app/(staff)/staff/page.tsx index 78d3c2f..1a9d315 100644 --- a/adminsite/app/(staff)/staff/page.tsx +++ b/adminsite/app/(staff)/staff/page.tsx @@ -35,9 +35,7 @@ export default function StaffDashboard() { if (injection.error instanceof NotConnected) return ; - 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() {
    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={ 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) : "", }))} /> 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() { }, ]} /> - + All licences @@ -139,14 +127,9 @@ export default function StaffDashboard() {

    Recent activity

    -

    - Every licence issued, relinked or reaped, newest first. -

    +

    Every licence issued, relinked or reaped, newest first.

    - + Full audit
    @@ -163,9 +146,7 @@ export default function StaffDashboard() {
    {(audit.data ?? []).slice(0, 12).map((e, n) => ( - + diff --git a/adminsite/app/(staff)/staff/plans/page.tsx b/adminsite/app/(staff)/staff/plans/page.tsx index ba98226..d93d617 100644 --- a/adminsite/app/(staff)/staff/plans/page.tsx +++ b/adminsite/app/(staff)/staff/plans/page.tsx @@ -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); const dirty = JSON.stringify(draft) !== JSON.stringify(plan); @@ -43,9 +35,7 @@ function AllowanceForm({
    {LIMIT_FIELDS.map((f) => ( ))} -

    - Changes apply to licences issued from now on. Existing licences - snapshotted their plan and are unaffected. -

    +

    Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.

    -
    @@ -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 (
    {draft && original && ( 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) => (
    -

    - {deployment === "cloud" ? "Cloud" : "Self-Hosted"} -

    +

    {deployment === "cloud" ? "Cloud" : "Self-Hosted"}

    {(plans.data ?? []) .filter((p) => p.deployment === deployment) .map((p) => ( -
    +
    -

    - {p.name} -

    +

    {p.name}

    {p.deployment}/{p.tier}
    - setDraft(next)} - /> + setDraft(next)} />
    ))}
    diff --git a/adminsite/components/AppBar.tsx b/adminsite/components/AppBar.tsx index b4fd219..8bfcc6e 100644 --- a/adminsite/components/AppBar.tsx +++ b/adminsite/components/AppBar.tsx @@ -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 (
    @@ -46,9 +36,7 @@ export function AppBar({
    Vantage - - HQ - + HQ {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} diff --git a/adminsite/components/Button.tsx b/adminsite/components/Button.tsx index ca329d7..1904b65 100644 --- a/adminsite/components/Button.tsx +++ b/adminsite/components/Button.tsx @@ -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 wrapped around a
    -
    -                {blob}
    -            
    +
    {blob}
      {steps.map((body, i) => ( -
    1. - - {i + 1} - +
    2. + {i + 1} {body}
    3. ))} diff --git a/adminsite/components/MembersPanel.tsx b/adminsite/components/MembersPanel.tsx index 9999591..1cec338 100644 --- a/adminsite/components/MembersPanel.tsx +++ b/adminsite/components/MembersPanel.tsx @@ -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 (

      Who can sign in

      -

      - Each person here has a real user inside this instance and signs in with their - Vantage HQ password. -

      +

      Each person here has a real user inside this instance and signs in with their Vantage HQ password.

      {error &&

      {error}

      }
        {(members.data ?? []).map((m) => ( -
      • +
      • {m.email} {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 }) {
      • ))} - {members.data?.length === 0 && ( -
      • Nobody has been added yet.
      • - )} + {members.data?.length === 0 &&
      • Nobody has been added yet.
      • }
      {canManage && ( @@ -129,14 +116,8 @@ export function MembersPanel({ instanceId }: { instanceId: string }) { }} >
      diff --git a/adminsite/components/NotConnected.tsx b/adminsite/components/NotConnected.tsx index 21d1f12..23cfa10 100644 --- a/adminsite/components/NotConnected.tsx +++ b/adminsite/components/NotConnected.tsx @@ -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 }) {

      Not connected to the licensing service

      {url ? (

      - This build points at ADMIN_API_URL ={" "} - {url}, which did not respond. + This build points at ADMIN_API_URL = {url}, which did not respond.

      ) : (

      - ADMIN_API_URL was not set when this app was - built, so there is nowhere to send requests. + ADMIN_API_URL was not set when this app was built, so there is nowhere to send requests.

      )}

      - The value is baked in when the image is built and has to be reachable from your - browser, not just from the server. It also has to appear in the licensing - service’s ADMIN_ORIGIN, or the browser blocks every request. + The value is baked in when the image is built and has to be reachable from your browser, not just from the server. It also has to appear in the licensing service’s{" "} + ADMIN_ORIGIN, or the browser blocks every request.

    ); diff --git a/adminsite/components/PageFrame.tsx b/adminsite/components/PageFrame.tsx index 1934193..171e729 100644 --- a/adminsite/components/PageFrame.tsx +++ b/adminsite/components/PageFrame.tsx @@ -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
    {children}
    ; 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 (
    -

    - {title} -

    - {count !== undefined && ( - {count} - )} +

    {title}

    + {count !== undefined && {count}}
    {children}
    @@ -59,9 +41,7 @@ export function RailFacts({ rows }: { rows: { label: string; value: React.ReactN {rows.map((r) => (
    {r.label}
    -
    - {r.value} -
    +
    {r.value}
    ))} diff --git a/adminsite/components/PageHeader.tsx b/adminsite/components/PageHeader.tsx index 39157f0..0bdc66e 100644 --- a/adminsite/components/PageHeader.tsx +++ b/adminsite/components/PageHeader.tsx @@ -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 (
    {back && ( - + ← {back.label} )} @@ -80,12 +77,8 @@ export function PageHeader({
    {record?.map((f) => ( - - {f.key} - - - {f.value} - + {f.key} + {f.value} {f.copy && } ))} diff --git a/adminsite/components/RelinkPanel.tsx b/adminsite/components/RelinkPanel.tsx index 01bd1c9..c897667 100644 --- a/adminsite/components/RelinkPanel.tsx +++ b/adminsite/components/RelinkPanel.tsx @@ -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 (

    Moved to a new server?

    -

    - Relinking issues a replacement licence for the new install, covering the rest of - your current term. -

    - {open && !exhausted && ( - setValue(e.target.value)} - error={error} - hint="From Settings → Licence on the new install." - /> - )} +

    Relinking issues a replacement licence for the new install, covering the rest of your current term.

    + {open && !exhausted && setValue(e.target.value)} error={error} hint="From Settings → Licence on the new install." />}
    - - {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`}
    diff --git a/adminsite/lib/theme.tsx b/adminsite/lib/theme.tsx index c7370e2..0eb9504 100644 --- a/adminsite/lib/theme.tsx +++ b/adminsite/lib/theme.tsx @@ -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("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){}`; diff --git a/site/app/about/page.tsx b/site/app/about/page.tsx index 06578f2..4b6a355 100644 --- a/site/app/about/page.tsx +++ b/site/app/about/page.tsx @@ -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 ( - <> -
    - About -

    - Built for the fleet nobody was given a budget to manage. -

    + return ( + <> +
    + About +

    We built this because we needed it.

    -
    -
    -

    - 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's head. - Two of the boxes were not on it. -

    -

    - 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. -

    -

    - So it began with one job done properly: hold authorized_keys 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. -

    -

    - Today it runs across homelabs, small hosting providers, and agencies who inherit client servers and need - to prove who can reach them. -

    -
    +
    +
    +

    + Vantage is made by HostXtra, a small company in the UK. We manage servers for a living our own and other people's and we built Vantage because we were doing that job + badly with a spreadsheet and a folder of scripts. +

    +

    + 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's head, and two of them weren't on it. That took an afternoon, and it should have taken a minute. +

    +

    + Everything that existed to fix this was built for companies far larger than ours. We wanted one screen that answered "who can get into this machine" and let us + change the answer. Once that worked, the rest followed: if there'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. +

    +

    We use Vantage every day to run our own servers. When something about it is annoying, it's annoying us too, which is the main reason it keeps getting better.

    +
    -
    - How it is built -
    -
    - SERVER -
    -

    Go, MongoDB, Redis

    -

    - One Go binary serving REST for the interface and gRPC for agents. MongoDB holds everything durable; - Redis holds sessions and nothing else. -

    +
    + The company +
    +
    + WHO +
    +

    HostXtra

    +

    A small independent team in the United Kingdom. No investors, nobody to answer to but the people paying us.

    +
    +
    +
    + HOW WE EARN +
    +

    Subscriptions, and that's all

    +

    We're paid by our customers. We don't sell data, we don't sell ads, and there's no free tier being mined for anything.

    +
    +
    +
    + WHO YOU GET +
    +

    The people who wrote it

    +

    Support goes to the same small group that builds the product. There is no first line, and nobody reading from a script.

    +
    +
    +
    + IF WE STOP +
    +

    You can host it yourself

    +

    Every tier can run on your own hardware, and your data is exportable whenever you want it. You are not stuck with us.

    +
    +
    +
    +
    -
    -
    - WEB -
    -

    Next.js

    -

    - An operations interface, not a brochure: dense tables, live log streams, and state you can read at a - glance. -

    -
    -
    -
    - AGENT -
    -

    Go, Linux and Windows

    -

    - A single static binary under systemd or as a Windows service. No runtime, no dependencies, no - package manager involved. -

    -
    -
    -
    - CONSOLE -
    -

    Guacamole

    -

    - Protocol handling is a solved problem. We proxy the connection and manage the credentials around it. -

    -
    -
    -
    -
    -
    -
    +
    -
    - Security posture -

    - The parts worth being specific about. -

    -
    -
    - Tokens -

    Hashed, never stored plain

    -

    - Agent tokens and the secrets read token are held as SHA-256 hashes. The plaintext exists on the - agent's own disk at 0600 and nowhere else. -

    -
    -
    - At rest -

    AES-256-GCM

    -

    - Private keys, passphrases, vault secrets, identity provider secrets and console credentials are encrypted - with a key held only by your deployment. -

    -
    -
    - One-time -

    Tokens that expire and spend

    -

    - Pre-registration tokens last an hour and work once. Console session tokens are consumed the moment the - tunnel opens. -

    -
    -
    - Recorded -

    Every mutation is audited

    -

    - Assignments, revocations, runs, console sessions, secret reveals and settings changes are attributed and - kept. -

    -
    -
    -
    - - ); +
    + Who uses it +

    People with more servers than time.

    +
    +
    + Home labs +

    A rack in the spare room

    +

    A handful of machines you built yourself, and no interest in running enterprise software to keep track of them.

    +
    +
    + Small teams +

    Two or three people, one estate

    +

    Everyone needs access to everything, and everyone needs to know what the others changed. Usually the point where the spreadsheet stops working.

    +
    +
    + Agencies +

    Servers you inherited

    +

    Machines set up by somebody who has since left, for a client who now wants a straight answer about who can get into them.

    +
    +
    + Hosting +

    Small providers

    +

    Lots of machines, thin margins, and a need to prove access is controlled without buying something priced per seat.

    +
    +
    +
    + +
    + How we work +

    A few things we've decided.

    +
    +
    + SMALL +
    +

    We say no to most things

    +

    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.

    +
    +
    +
    + HONEST +
    +

    The price is on the website

    +

    All three tiers, including the largest, can be bought without speaking to anybody. There is no hidden pricing and no "contact us" tier.

    +
    +
    +
    + OPEN +
    +

    Bugs are in public

    +

    Our issue tracker is open to read. If something is broken, you can see whether we already know about it.

    +
    +
    +
    + CAREFUL +
    +

    We take security reports seriously

    +

    Report something and you'll hear back from a person within three days. We would much rather you told us than didn't.

    +
    +
    +
    +
    + + ); } diff --git a/site/app/contact/page.tsx b/site/app/contact/page.tsx index b50a32e..12c03d8 100644 --- a/site/app/contact/page.tsx +++ b/site/app/contact/page.tsx @@ -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 (
    Contact -

    Tell us what your fleet looks like.

    +

    Get in touch.

    @@ -45,7 +45,7 @@ export default function ContactPage() {
    -

    Pick the right door and you will get a faster answer.

    +

    If one of these is a better fit than the form, use it you'll get an answer sooner.

    {CHANNELS.map((channel) => (

    {channel.title}

    diff --git a/site/app/layout.tsx b/site/app/layout.tsx index cd86409..888c6d6 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -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" }, }; diff --git a/site/app/page.tsx b/site/app/page.tsx index 7cc3110..0dcd515 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -6,21 +6,21 @@ export default function OverviewPage() { <>
    - Self-hosted fleet control plane -

    Your servers, under one pane of glass you actually own.

    + Server management you can host yourself +

    One place to manage every server you look after.

    - 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.

    - Create your instance + Get started free What it does
    -

    Free for 3 servers · Linux and Windows agents · Self-host the whole stack

    +

    Free for 3 servers · Linux and Windows · Hosted by us or by you

    @@ -28,25 +28,25 @@ export default function OverviewPage() {
    The problem -

    Six tools, six sources of truth, one afternoon lost.

    +

    Nothing knows about anything else.

    - Most small fleets end up with keys in a spreadsheet, scripts in someone'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'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.

    ONE AGENT
    -

    Everything rides one connection

    -

    Keys, steps, checks and inventory all travel over the same outbound link. Installing a second thing is not the answer.

    +

    One thing to install

    +

    Access, scripts, uptime checks and hardware stats all come down the same connection. You don't install a second thing to get the second feature.

    ONE RECORD
    -

    Every change is an audit event

    -

    Assignments, revocations, runs, console sessions and settings changes all land in the same log, attributed to a person.

    +

    Everything is written down

    +

    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.

    @@ -55,73 +55,76 @@ export default function OverviewPage() {
    Capabilities -

    One control plane, six jobs.

    +

    What it does.

    Access

    SSH keys

    - Assign public keys per server and revoke them softly. The agent diffs desired state against the file and rewrites authorized_keys 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.

      -
    • fingerprint deduplication
    • -
    • agent-side keypair generation
    • -
    • revocation history preserved
    • +
    • spots the same key added twice
    • +
    • can create a key on the server itself
    • +
    • keeps a record of access you removed
    - Execution + Scripts

    Workflows

    -

    A library of bash and PowerShell steps with declared inputs, outputs and secret references, composed into workflows that target a set of servers.

    +

    Keep your shell and PowerShell scripts in one library instead of scattered across machines, then run them in order across a group of servers.

      -
    • live streamed step logs
    • -
    • stop, continue or retry on failure
    • -
    • values passed between steps
    • +
    • watch the output as it happens
    • +
    • stop, carry on or retry when a step fails
    • +
    • pass values from one step to the next
    Uptime

    Monitors

    -

    HTTP, TCP, ICMP and TLS checks, run either from the control plane or from an agent inside the target network.

    +

    + 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't see. +

      -
    • incidents and uptime history
    • -
    • certificate expiry warnings
    • -
    • alerts to five channel types
    • +
    • a history of outages and uptime
    • +
    • warnings before a certificate expires
    • +
    • alerts by email, Slack, Discord or Telegram
    - Secrets + Passwords

    Vault

    -

    Grouped key/value secrets encrypted with AES-256-GCM, injected into workflow steps at execution and never written to logs.

    +

    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.

      -
    • read token for External Secrets Operator
    • -
    • rotatable, hashed at rest
    • -
    • reveal is an audited action
    • +
    • grouped by the thing they belong to
    • +
    • encrypted with a key only you hold
    • +
    • looking at one is recorded
    Access

    Browser console

    -

    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.

    +

    Open a terminal or a remote desktop from the browser. Nothing to install on the machine you're sitting at, and every session goes in the log.

      -
    • one-time session tokens
    • -
    • credentials consumed on connect
    • -
    • no client software
    • +
    • links that only work once
    • +
    • passwords used once, then discarded
    • +
    • works from a borrowed laptop
    Health -

    Inventory and updates

    -

    CPU, memory, swap, disks and kernel reported continuously, alongside pending OS package updates you can apply from the interface.

    +

    Hardware and updates

    +

    See processor, memory, disk and operating system for every server, along with the updates waiting to be installed and install them without logging in.

      -
    • metrics every 30 seconds
    • -
    • one-click package updates
    • -
    • agents update themselves
    • +
    • refreshed every 30 seconds
    • +
    • updates applied from the same screen
    • +
    • agents keep themselves up to date
    @@ -129,27 +132,27 @@ export default function OverviewPage() {
    Getting started -

    Four steps, and the first three take a minute.

    +

    Getting your first server in.

    FIRST -

    Create an instance

    -

    You become the owner. Everything inside is invisible to every other instance.

    +

    Set up your account

    +

    You're the owner. Nobody outside your account can see anything in it.

    THEN

    Add a server

    -

    Copy the install one-liner. It expires in an hour and works exactly once.

    +

    Copy the install command we show you and paste it into the server. It works once, then expires.

    THEN -

    Assign a key

    -

    Paste a public key and tick the servers. It lands inside 30 seconds.

    +

    Give yourself access

    +

    Paste your public key and tick the servers it should reach. It arrives within 30 seconds.

    AFTER -

    Build from there

    -

    Add checks, write a step, invite the team, connect your identity provider.

    +

    Add the rest as you need it

    +

    Set up an uptime check, save a script, invite the people you work with.

    @@ -168,7 +171,7 @@ export default function OverviewPage() {

    Three servers, free, no card.

    -

    Create an instance, install one agent, and watch a key land on a real box.

    +

    Set up an account, add one server, and see it working before you decide anything.

    Get started diff --git a/site/app/platform/page.tsx b/site/app/platform/page.tsx index 4c1cfcc..b70feba 100644 --- a/site/app/platform/page.tsx +++ b/site/app/platform/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; 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 +11,43 @@ export default function PlatformPage() {
    Platform

    How the pieces fit together.

    -

    Three moving parts: a control plane you run, an agent on each server, and one outbound connection between them.

    +

    There are only three parts: Vantage itself, a small program on each of your servers, and one connection between them that your server opens.

    -

    The agent never listens.

    +

    Your servers call us, not the other way round.

    - 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't open a port in your firewall, you don't need a fixed address, and a + machine sitting behind a home router works exactly like one in a data centre.

    - 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.

    - POLL + CHECKING
    -

    SyncKeys, every 30s

    -

    The desired key set for this server. Unchanged state means no disk write at all.

    +

    Access, every 30 seconds

    +

    The server asks who should be able to reach it. If nothing has changed, nothing is touched.

    - PUSH + ASKING
    -

    Command stream

    -

    Generate a key, run a step, apply updates, update the agent, clean up a workspace.

    +

    Anything you press

    +

    Run a script, open a session, install updates, create a key. Sent down the connection as you ask for it.

    - REPORT + REPORTING
    -

    Inventory and checks

    -

    Metrics every 30 seconds, a full hardware snapshot every 15 minutes, and monitor results as they complete.

    +

    Health and uptime

    +

    Load and free space every 30 seconds, full hardware details every 15 minutes, uptime checks as they finish.

    @@ -55,91 +55,90 @@ export default function PlatformPage() {
    - Write path -

    The file is never half-written.

    -
    + Changing access +

    Careful about the file it owns.

    +

    - The agent computes the desired authorized_keys 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.

    -
    -                        // agent poll, simplified
    -                        {"\n"}
    -                        desired := client.SyncKeys(serverID, token){"\n"}
    -                        current := keys.ReadAuthorizedKeys(){"\n\n"}
    -                        if !keys.StateChanged(current, desired) {"{"}
    -                        {"\n    "}
    -                        // nothing to do
    -                        {"\n    "}
    -                        return nil{"\n"}
    -                        {"}"}
    -                        {"\n\n"}
    -                        keys.WriteAuthorizedKeys(desired){"\n"}
    -                        // write .tmp, os.Rename(), chmod 0600
    -                    
    +
    +
    + SCOPE +
    +

    One file, nothing else

    +

    It doesn't manage your packages, your configuration or your users. Anything else that happens is a script you wrote and asked it to run.

    +
    +
    +
    + REMOVING +
    +

    Taking access away

    +

    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.

    +
    +
    +
    - Tenancy and identity -

    Instances are the boundary.

    + People and access +

    Who can see what.

    - Isolation -

    Scoped at the query

    -

    - 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. -

    + Separation +

    Your account is its own world

    +

    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.

    Roles

    Owner, admin, member

    -

    Members operate the fleet. Admins and owners manage people, identity settings and the secrets read token.

    +

    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.

    - Identity -

    Local or OIDC, per instance

    -

    Sign in with email and password, or connect your own provider. Each instance configures its own issuer and client.

    + Signing in +

    Email, or your own login

    +

    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.

    - Sessions -

    Server-side, 24 hours

    -

    Cookies carry an opaque identifier and nothing else. Session bodies live in Redis, so losing it signs everyone out and costs no durable data.

    + Storage +

    Encrypted, and yours

    +

    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.

    - What we do not build -

    The scope is the feature.

    + What it deliberately doesn't do +

    Things we left out on purpose.

    - NOT A PROXY + NOT IN THE WAY
    -

    We are never in the SSH path

    -

    Vantage assigns keys; your client connects straight to the box. If our control plane is down, your SSH still works.

    +

    Your connections don't go through us

    +

    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.

    - NO CUSTODY + NOT A KEYRING
    -

    Private keys stay put by default

    -

    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.

    +

    We'd rather not hold your private keys

    +

    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.

    - NO PER-USER + NOT PER-PERSON
    -

    Root, not every account

    -

    Vantage manages one file per server. Per-user key management is a different product with a different failure mode.

    +

    Access to the server, not to accounts on it

    +

    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.

    - NO PLUGINS + NO ADD-ONS
    -

    An agent you can read in an evening

    -

    A few thousand lines of Go with no extension system. Auditability beats extensibility on a binary that runs as root.

    +

    Nothing to extend, on purpose

    +

    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.

    diff --git a/site/app/pricing/page.tsx b/site/app/pricing/page.tsx index 70d28c6..97909a4 100644 --- a/site/app/pricing/page.tsx +++ b/site/app/pricing/page.tsx @@ -40,15 +40,15 @@ export default function PricingPage() { Pick a tier, then pay per server.

    - 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.

    Free
    -

    A homelab, a couple of VPSes, and the keys on your own laptop.

    +

    For a home lab or a couple of servers you look after on your own.

    £0 3 servers @@ -69,12 +69,12 @@ export default function PricingPage() {
    Professional
    -

    Real infrastructure, and more than one person holding the keys.

    +

    For servers people rely on, looked after by more than one person.

    £19 / month, 3 servers
    -

    £5 per extra server. £190 a year saves two months. Self-hosted is £190 a year.

    +

    £2.50 per extra server. £190 a year saves two months. Self-hosted is £190 a year.

    • 3 servers included, add as many as you like
    • Unlimited monitors, secrets and channels
    • @@ -90,12 +90,12 @@ export default function PricingPage() {
      Enterprise
      -

      A fleet, a compliance question, and someone who wants a phone number.

      +

      For larger estates, and for anyone who has to answer to an auditor.

      £49 / month, 10 servers
      -

      £5 per extra server. £490 a year saves two months. Self-hosted is £490 a year.

      +

      £2.50 per extra server. £490 a year saves two months. Self-hosted is £490 a year.

      • 10 servers included, add as many as you like
      • Everything in Professional
      • @@ -158,7 +158,7 @@ export default function PricingPage() { COUNTING

        What counts as a server

        -

        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.

        +

        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.

      @@ -178,8 +178,11 @@ export default function PricingPage() {
      LIMITS
      -

      Reaching the cap

      -

      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.

      +

      Reaching your limit

      +

      + 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. +

      @@ -187,18 +190,18 @@ export default function PricingPage() {

      What happens when you stop paying

      - 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't change anything until you renew.

      SELF-HOSTED
      -

      Why self-hosted is annual only

      +

      Why self-hosted is yearly only

      - 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't switch + one off partway through, so we sell it a year at a time.

      @@ -206,7 +209,9 @@ export default function PricingPage() { EXIT

      Leaving

      -

      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. +

    diff --git a/site/app/start/page.tsx b/site/app/start/page.tsx index 60dcda3..d32fcf4 100644 --- a/site/app/start/page.tsx +++ b/site/app/start/page.tsx @@ -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() { Vantage Cloud

    Create your account.

    - 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.

    @@ -29,34 +29,34 @@ export default function StartPage() { FIRST

    Confirm your email

    -

    We send a link that works once. Your account is created when you open it, not before.

    +

    We send you a link that works once. Nothing is created until you open it.

    THEN
    -

    Create your instance

    -

    One click in the portal. It gets its own subdomain and a free licence, and you are its owner.

    +

    Set up your Vantage

    +

    One click. It gets its own web address, and you're the owner of it.

    THEN

    Add a key and a server

    -

    Paste your public key, then run the install command as root. It expires in an hour and works once.

    +

    Paste in your public key, then run the install command on the server. It works once and expires after an hour.

    THEN
    -

    Watch it register

    -

    The server moves from pending to active on first sync, usually inside 30 seconds.

    +

    Watch it appear

    +

    The server turns from pending to active as soon as it checks in, usually within 30 seconds.

    -                        curl -fsSL https://vantage.hostxtra.co.uk/install | \{"\n"}
    +                        curl -fsSL https://vantage.mydomain.com/install | \{"\n"}
                             {"  "}bash -s -- --server-id=$ID --token=$TOKEN
                         
    diff --git a/site/components/AccountForm.tsx b/site/components/AccountForm.tsx index aea7a49..be28172 100644 --- a/site/components/AccountForm.tsx +++ b/site/components/AccountForm.tsx @@ -29,10 +29,10 @@ export function AccountForm() {

    Check your email.

    - 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've sent you a confirmation link. Open it and your account is ready to use. The link works once, and expires after 24 hours.

    - 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't arrive, start again or email support@hostxtra.co.uk.

    ); diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx index a8f31cf..3b8311d 100644 --- a/site/components/Footer.tsx +++ b/site/components/Footer.tsx @@ -5,7 +5,7 @@ export function Footer() { return (
    -

    Vantage self-hosted fleet control for people who own their servers.

    +

    Vantage server management for people who own their servers.

    {NAV_LINKS.map((link) => ( {link.label} diff --git a/web/app/(app)/settings/license/page.tsx b/web/app/(app)/settings/license/page.tsx index db44d0c..e0b3ebc 100644 --- a/web/app/(app)/settings/license/page.tsx +++ b/web/app/(app)/settings/license/page.tsx @@ -9,7 +9,7 @@ import { Group } from "@/components/settings/Group"; import { inputClass } from "@/components/settings/Field"; /* - * A licence is a document — issued, dated, signed, and carrying a reference + * A licence is a document issued, dated, signed, and carrying a reference * you quote to support. This page is built to read as that document rather * than as another settings form, which is also how adminsite/ presents the * same object from the issuing side: state as a pill that carries a label and @@ -34,7 +34,7 @@ const STATE: Record // this app's to choose. Each one says what is wrong and what to do about it. const REASON: Record = { no_license: "No licence is installed on this instance. Paste one below to activate it.", - bad_signature: "This licence's signature could not be verified. It may have been edited or truncated in transit — paste the original again.", + bad_signature: "This licence's signature could not be verified. It may have been edited or truncated in transit paste the original again.", deployment_mismatch: "This licence was issued for a different kind of deployment, so it cannot be used here.", instance_mismatch: "This licence was issued for a different instance. Check the instance ID below against the one you bought against.", expired: "This licence has passed its expiry date. Your servers and monitors keep running, but changes are disabled until it is renewed.", @@ -60,25 +60,19 @@ const SUPPORT_LABELS: Record = { /* * Retention is a duration, not a cap, so it gets a fact rather than a meter. - * Allowance's bar needs a numerator and there isn't one — how much of a + * Allowance's bar needs a numerator and there isn't one how much of a * retention window have you "used"? */ function Retention({ days }: { days: number }) { const forever = days === UNLIMITED; return ( -

    - Audit history -

    +

    Audit history

    {forever ? "Kept" : days} - - {forever ? "indefinitely" : "days"} - -

    -

    - {forever ? "Never trimmed" : "Older entries removed daily"} + {forever ? "indefinitely" : "days"}

    +

    {forever ? "Never trimmed" : "Older entries removed daily"}

    ); } @@ -137,9 +131,7 @@ function Feature({ label, included }: { label: string; included: boolean }) { {included ? "✓" : "–"} {label} - - {included ? "Included" : "Not included"} - + {included ? "Included" : "Not included"}
    ); } @@ -162,9 +154,7 @@ function RecordPanel({ license }: { license: LicenseInfo }) { already carries the colour. A dot would be the third telling. */}

    {s.label}

    {license.tier &&

    {license.tier} tier

    } - {license.support_level && SUPPORT_LABELS[license.support_level] && ( -

    {SUPPORT_LABELS[license.support_level]}

    - )} + {license.support_level && SUPPORT_LABELS[license.support_level] &&

    {SUPPORT_LABELS[license.support_level]}

    }
    {license.reason &&

    {REASON[license.reason] ?? license.reason}

    } @@ -297,51 +287,51 @@ export default function LicensePage() { ) : ( - - - -
    - {nameFor(s.instance_id) ?? ( - Not linked yet - )} -
    {nameFor(s.instance_id) ?? Not linked yet} {s.tier.replace("_", " ")} {s.term} {s.status} - {formatDate(s.current_period_end)} - {formatDate(s.current_period_end)}
    - {new Date(e.created_at).toISOString().slice(11, 16)} - {new Date(e.created_at).toISOString().slice(11, 16)} {e.action} {e.target ?? "—"} {e.actor}