Compare commits

...
8 Commits
Author SHA1 Message Date
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
62 changed files with 1613 additions and 1175 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ 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)'
+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
+3 -2
View File
@@ -1,6 +1,7 @@
//go:build !linux
//go:build !linux && !windows
// Inventory collection is Linux-only. This no-op stands in everywhere else.
// Inventory collection has Linux and Windows implementations. This no-op stands
// in everywhere else.
//
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
// without it this file compiles on Linux too and collides with collect_linux.go.
+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 {
+1 -1
View File
@@ -117,7 +117,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
+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
@@ -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"]
+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
}
+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}
+450
View File
@@ -0,0 +1,450 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Mode = "cloud" | "self";
type Palette = {
ink: string;
ink3: string;
rule: string;
panel: string;
accent: string;
up: string;
pend: string;
mono: string;
sans: string;
};
type Node = {
label: string;
sub: string;
x: number;
y: number;
w: number;
h: number;
state: "up" | "pend";
flash: number;
};
type Wire = {
node: number;
ax: number;
ay: number;
bx: number;
by: number;
c1x: number;
c1y: number;
c2x: number;
c2y: number;
};
type Packet = {
wire: number;
t: number;
speed: number;
/** true when travelling control plane to agent, which only happens down a wire the agent opened. */
inbound: boolean;
};
const AGENTS: { label: string; sub: string; state: "up" | "pend" }[] = [
{ label: "proxmox-node-1", sub: "4 keys", state: "up" },
{ label: "db-primary", sub: "3 keys", state: "up" },
{ label: "edge-gw-02", sub: "2 keys", state: "up" },
{ label: "win-build-01", sub: "windows", state: "pend" },
];
const MODES: { id: Mode; label: string }[] = [
{ id: "cloud", label: "Hosted by us" },
{ id: "self", label: "Hosted by you" },
];
const NOTES: Record<Mode, string> = {
cloud: "We run Vantage. Your servers reach out to it, so nothing needs to be open to the internet on your side.",
self: "You run Vantage too, on your own hardware. Nothing leaves your network at all, and there is nothing to phone home to.",
};
function readPalette(el: HTMLElement): Palette {
const s = getComputedStyle(el);
const v = (name: string, fallback: string) => s.getPropertyValue(name).trim() || fallback;
return {
ink: v("--ink", "#0a1b33"),
ink3: v("--ink-3", "#6c7f96"),
rule: v("--rule", "#cdd6e2"),
panel: v("--panel", "#ffffff"),
accent: v("--accent", "#0b2a58"),
up: v("--up", "#2f8a60"),
pend: v("--pend", "#b0801f"),
mono: `11px ${v("--mono", "monospace")}`,
sans: `600 15px ${v("--sans", "sans-serif")}`,
};
}
function pointAt(w: Wire, t: number) {
const u = 1 - t;
const a = u * u * u;
const b = 3 * u * u * t;
const c = 3 * u * t * t;
const d = t * t * t;
return {
x: a * w.ax + b * w.c1x + c * w.c2x + d * w.bx,
y: a * w.ay + b * w.c1y + c * w.c2y + d * w.by,
};
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export function TopologyDiagram() {
const [mode, setMode] = useState<Mode>("cloud");
const canvasRef = useRef<HTMLCanvasElement>(null);
const modeRef = useRef<Mode>(mode);
modeRef.current = mode;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let palette = readPalette(document.documentElement);
let width = 0;
let height = 0;
let nodes: Node[] = [];
let wires: Wire[] = [];
let plane = { x: 0, y: 0, w: 0, h: 0 };
let boundary: { x: number; y: number; w: number; h: number; label: string; split: number | null } | null = null;
let stacked = false;
const packets: Packet[] = [];
let planePulse = 0;
let sinceSpawn = 0;
let sinceCommand = 2.2;
let raf = 0;
let last = 0;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const layout = () => {
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
stacked = width < 660;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const pad = stacked ? 14 : 22;
const nodeW = stacked ? Math.min(150, (width - pad * 2 - 16) / 2) : 168;
const nodeH = 46;
if (stacked) {
plane = { x: width / 2 - 100, y: pad + 30, w: 200, h: 78 };
const cols = 2;
const gapX = 14;
const gapY = 14;
const gridW = cols * nodeW + gapX;
const startX = width / 2 - gridW / 2;
const startY = plane.y + plane.h + (mode === "cloud" ? 84 : 66);
nodes = AGENTS.map((a, i) => ({
label: a.label,
sub: a.sub,
state: a.state,
flash: 0,
w: nodeW,
h: nodeH,
x: startX + (i % cols) * (nodeW + gapX),
y: startY + Math.floor(i / cols) * (nodeH + gapY),
}));
} else {
const colX = pad + 8;
plane = { x: width - pad - 214, y: height / 2 - 46, w: 206, h: 92 };
const total = AGENTS.length * nodeH + (AGENTS.length - 1) * 16;
const startY = height / 2 - total / 2;
nodes = AGENTS.map((a, i) => ({
label: a.label,
sub: a.sub,
state: a.state,
flash: 0,
w: nodeW,
h: nodeH,
x: colX,
y: startY + i * (nodeH + 16),
}));
}
wires = nodes.map((n, i) => {
if (stacked) {
const ax = n.x + n.w / 2;
const ay = n.y;
const bx = plane.x + plane.w * (0.2 + 0.2 * i);
const by = plane.y + plane.h;
const mid = (ay + by) / 2;
return { node: i, ax, ay, bx, by, c1x: ax, c1y: mid, c2x: bx, c2y: mid };
}
const ax = n.x + n.w;
const ay = n.y + n.h / 2;
const bx = plane.x;
const by = plane.y + plane.h * (0.24 + 0.17 * i);
const mid = ax + (bx - ax) * 0.5;
return { node: i, ax, ay, bx, by, c1x: mid, c1y: ay, c2x: mid, c2y: by };
});
if (modeRef.current === "self") {
boundary = { x: pad - 8, y: pad - 8, w: width - (pad - 8) * 2, h: height - (pad - 8) * 2, label: "YOUR NETWORK", split: null };
} else if (stacked) {
const y = plane.y + plane.h + 34;
boundary = { x: pad - 8, y, w: width - (pad - 8) * 2, h: height - y - (pad - 8), label: "YOUR NETWORK", split: y };
} else {
const split = plane.x - 46;
boundary = { x: pad - 8, y: pad - 8, w: split - (pad - 8), h: height - (pad - 8) * 2, label: "YOUR NETWORK", split };
}
};
const spawn = () => {
if (packets.length > 60) return;
const wire = Math.floor(Math.random() * wires.length);
packets.push({ wire, t: 0, speed: 0.34 + Math.random() * 0.16, inbound: false });
};
const drawWire = (w: Wire) => {
ctx.beginPath();
ctx.moveTo(w.ax, w.ay);
ctx.bezierCurveTo(w.c1x, w.c1y, w.c2x, w.c2y, w.bx, w.by);
ctx.stroke();
};
const draw = (dt: number) => {
ctx.clearRect(0, 0, width, height);
const mono = palette.mono;
// Boundary of what you own.
if (boundary) {
ctx.save();
ctx.setLineDash([5, 5]);
ctx.lineWidth = 1;
ctx.strokeStyle = palette.rule;
roundRect(ctx, boundary.x, boundary.y, boundary.w, boundary.h, 10);
ctx.stroke();
ctx.restore();
ctx.font = mono;
ctx.fillStyle = palette.ink3;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(boundary.label, boundary.x + 12, boundary.y + 13);
if (modeRef.current === "cloud") {
ctx.textAlign = stacked ? "left" : "right";
const x = stacked ? boundary.x + 12 : width - 22;
const y = stacked ? 18 : boundary.y + 13;
ctx.fillText("HOSTED BY US", x, y);
}
}
// Wires. Drawn under everything, because they are infrastructure.
ctx.lineWidth = 1.25;
ctx.strokeStyle = palette.rule;
wires.forEach(drawWire);
// Packets travelling the wires.
for (let i = packets.length - 1; i >= 0; i--) {
const p = packets[i];
p.t += p.speed * dt;
if (p.t >= 1) {
if (p.inbound) nodes[wires[p.wire].node].flash = 1;
else planePulse = 1;
packets.splice(i, 1);
continue;
}
const w = wires[p.wire];
const pos = pointAt(w, p.inbound ? 1 - p.t : p.t);
const fade = Math.min(1, Math.sin(p.t * Math.PI) * 2.2);
ctx.globalAlpha = fade;
ctx.fillStyle = p.inbound ? palette.accent : palette.up;
ctx.beginPath();
ctx.arc(pos.x, pos.y, p.inbound ? 3.4 : 2.4, 0, Math.PI * 2);
ctx.fill();
if (p.inbound) {
ctx.globalAlpha = fade * 0.22;
ctx.beginPath();
ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// Agents.
for (const n of nodes) {
if (n.flash > 0) {
ctx.globalAlpha = n.flash * 0.5;
ctx.strokeStyle = palette.accent;
ctx.lineWidth = 2;
roundRect(ctx, n.x - 2, n.y - 2, n.w + 4, n.h + 4, 8);
ctx.stroke();
ctx.globalAlpha = 1;
n.flash = Math.max(0, n.flash - dt * 1.6);
}
ctx.fillStyle = palette.panel;
ctx.strokeStyle = palette.rule;
ctx.lineWidth = 1;
roundRect(ctx, n.x, n.y, n.w, n.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = n.state === "up" ? palette.up : palette.pend;
ctx.beginPath();
ctx.arc(n.x + 14, n.y + n.h / 2, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = mono;
ctx.fillStyle = palette.ink;
ctx.fillText(n.label, n.x + 26, n.y + n.h / 2 - 6);
ctx.fillStyle = palette.ink3;
ctx.fillText(n.sub, n.x + 26, n.y + n.h / 2 + 8);
}
// Control plane.
if (planePulse > 0) {
ctx.globalAlpha = planePulse * 0.35;
ctx.strokeStyle = palette.accent;
ctx.lineWidth = 2;
roundRect(ctx, plane.x - 3 - planePulse * 4, plane.y - 3 - planePulse * 4, plane.w + 6 + planePulse * 8, plane.h + 6 + planePulse * 8, 10);
ctx.stroke();
ctx.globalAlpha = 1;
planePulse = Math.max(0, planePulse - dt * 1.5);
}
ctx.fillStyle = palette.accent;
roundRect(ctx, plane.x, plane.y, plane.w, plane.h, 8);
ctx.fill();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const cx = plane.x + plane.w / 2;
ctx.fillStyle = palette.panel;
ctx.font = palette.sans;
ctx.fillText("Vantage", cx, plane.y + plane.h / 2 - 11);
ctx.font = mono;
ctx.globalAlpha = 0.78;
ctx.fillText(modeRef.current === "cloud" ? "vantage.hostxtra.co.uk" : "your own hardware", cx, plane.y + plane.h / 2 + 8);
ctx.globalAlpha = 1;
// Direction of travel, stated once rather than on every wire.
if (!stacked) {
ctx.font = mono;
ctx.fillStyle = palette.ink3;
ctx.textAlign = "center";
const midX = (nodes[0].x + nodes[0].w + plane.x) / 2;
ctx.fillText("your servers dial out", midX, height - 26);
}
};
const frame = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
sinceSpawn += dt;
if (sinceSpawn > 0.42) {
sinceSpawn = 0;
spawn();
}
sinceCommand += dt;
if (sinceCommand > 4.4) {
sinceCommand = 0;
const wire = Math.floor(Math.random() * wires.length);
packets.push({ wire, t: 0, speed: 0.62, inbound: true });
}
draw(dt);
raf = requestAnimationFrame(frame);
};
const start = () => {
cancelAnimationFrame(raf);
layout();
if (reduced.matches) {
packets.length = 0;
wires.forEach((_, i) => packets.push({ wire: i, t: 0.35 + i * 0.12, speed: 0, inbound: false }));
draw(0);
return;
}
last = performance.now();
raf = requestAnimationFrame(frame);
};
const onResize = () => start();
const observer = new ResizeObserver(onResize);
observer.observe(canvas);
const repaint = () => {
palette = readPalette(document.documentElement);
if (reduced.matches) draw(0);
};
const themeObserver = new MutationObserver(repaint);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
const scheme = window.matchMedia("(prefers-color-scheme: dark)");
const onScheme = repaint;
scheme.addEventListener("change", onScheme);
reduced.addEventListener("change", start);
start();
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
themeObserver.disconnect();
scheme.removeEventListener("change", onScheme);
reduced.removeEventListener("change", start);
};
}, [mode]);
return (
<figure className="topo">
<div className="topo__head">
<div className="topo__seg" role="group" aria-label="Where Vantage runs">
{MODES.map((m) => (
<button key={m.id} type="button" className={m.id === mode ? "topo__btn is-on" : "topo__btn"} aria-pressed={m.id === mode} onClick={() => setMode(m.id)}>
{m.label}
</button>
))}
</div>
<p className="topo__key">
<span className="topo__dot topo__dot--out" /> your server checking in
<span className="topo__dot topo__dot--in" /> something you asked for
</p>
</div>
<canvas
ref={canvasRef}
className="topo__canvas"
role="img"
aria-label={
mode === "cloud"
? "Four of your servers, inside your own network, each opening a connection out to Vantage running on our hardware. Nothing connects inward."
: "Four of your servers and Vantage itself, all inside your own network. The servers connect to Vantage, and nothing leaves your network."
}
/>
<figcaption className="topo__note">{NOTES[mode]}</figcaption>
</figure>
);
}
+6 -1
View File
@@ -19,6 +19,7 @@ export default function ServerConsolePage() {
disconnect: () => void;
setScale: (scale: number) => void;
resize: (width: number, height: number) => void;
focus: () => void;
} | null>(null);
const [protocol, setProtocol] = useState<string>(searchParams.get("protocol") || "");
@@ -274,7 +275,11 @@ export default function ServerConsolePage() {
<label className="text-sm text-text-secondary">Scale</label>
<select
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
onChange={(e) => {
setZoom(Number(e.target.value));
e.target.blur();
connectionRef.current?.focus();
}}
className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
<option value={0.5}>50%</option>
+50 -60
View File
@@ -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<LicenseState, { label: string; rule: string; text: string }>
// this app's to choose. Each one says what is wrong and what to do about it.
const REASON: Record<string, string> = {
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<string, string> = {
/*
* 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 (
<Card padding={false} className="p-4">
<p className="font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-tertiary">
Audit history
</p>
<p className="font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-tertiary">Audit history</p>
<p className="mt-2 text-2xl font-extrabold tracking-[-0.03em] tabular-nums text-text-primary">
{forever ? "Kept" : days}
<span className="ml-1.5 text-sm font-medium tracking-normal text-text-secondary">
{forever ? "indefinitely" : "days"}
</span>
</p>
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">
{forever ? "Never trimmed" : "Older entries removed daily"}
<span className="ml-1.5 text-sm font-medium tracking-normal text-text-secondary">{forever ? "indefinitely" : "days"}</span>
</p>
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">{forever ? "Never trimmed" : "Older entries removed daily"}</p>
</Card>
);
}
@@ -137,9 +131,7 @@ function Feature({ label, included }: { label: string; included: boolean }) {
{included ? "✓" : ""}
</span>
<span className="text-sm text-text-primary">{label}</span>
<span className={`ml-auto font-mono text-[0.62rem] uppercase tracking-[0.14em] ${included ? "text-success" : "text-text-tertiary"}`}>
{included ? "Included" : "Not included"}
</span>
<span className={`ml-auto font-mono text-[0.62rem] uppercase tracking-[0.14em] ${included ? "text-success" : "text-text-tertiary"}`}>{included ? "Included" : "Not included"}</span>
</div>
);
}
@@ -162,9 +154,7 @@ function RecordPanel({ license }: { license: LicenseInfo }) {
already carries the colour. A dot would be the third telling. */}
<h2 className={`text-2xl font-extrabold tracking-[-0.03em] ${s.text}`}>{s.label}</h2>
{license.tier && <p className="text-sm text-text-secondary">{license.tier} tier</p>}
{license.support_level && SUPPORT_LABELS[license.support_level] && (
<p className="text-sm text-text-secondary">{SUPPORT_LABELS[license.support_level]}</p>
)}
{license.support_level && SUPPORT_LABELS[license.support_level] && <p className="text-sm text-text-secondary">{SUPPORT_LABELS[license.support_level]}</p>}
</div>
{license.reason && <p className="mt-3 max-w-prose text-sm text-text-secondary">{REASON[license.reason] ?? license.reason}</p>}
@@ -297,51 +287,51 @@ export default function LicensePage() {
</Card>
</Group>
) : (
<Group label="Add or replace">
<Card>
<label htmlFor="licence-blob" className="mb-1.5 block text-sm font-medium text-text-secondary">
Licence key
</label>
<textarea
id="licence-blob"
className={`${inputClass} h-32 resize-y font-mono text-xs`}
placeholder="Paste your licence key"
value={blob}
onChange={(e) => {
setBlob(e.target.value);
setFileName("");
}}
/>
<div className="mt-3 flex flex-wrap items-center gap-3">
{/* A bare file input cannot be styled, so the button is the
label and the input itself is visually hidden. */}
<label className="inline-flex cursor-pointer items-center gap-2 rounded border border-border bg-surface px-3 py-1.5 text-sm font-semibold text-text-primary transition-colors hover:border-text-tertiary focus-within:ring-2 focus-within:ring-accent">
<input
type="file"
accept=".lic,.txt"
className="sr-only"
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setBlob((await f.text()).trim());
setFileName(f.name);
}}
/>
Choose a file
<Group label="Add or replace">
<Card>
<label htmlFor="licence-blob" className="mb-1.5 block text-sm font-medium text-text-secondary">
Licence key
</label>
<span className="text-xs text-text-tertiary">{fileName ? `Loaded ${fileName}` : "or paste the key above"}</span>
</div>
<textarea
id="licence-blob"
className={`${inputClass} h-32 resize-y font-mono text-xs`}
placeholder="Paste your licence key"
value={blob}
onChange={(e) => {
setBlob(e.target.value);
setFileName("");
}}
/>
{error && <p className="mt-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3">
{/* A bare file input cannot be styled, so the button is the
label and the input itself is visually hidden. */}
<label className="inline-flex cursor-pointer items-center gap-2 rounded border border-border bg-surface px-3 py-1.5 text-sm font-semibold text-text-primary transition-colors hover:border-text-tertiary focus-within:ring-2 focus-within:ring-accent">
<input
type="file"
accept=".lic,.txt"
className="sr-only"
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setBlob((await f.text()).trim());
setFileName(f.name);
}}
/>
Choose a file
</label>
<span className="text-xs text-text-tertiary">{fileName ? `Loaded ${fileName}` : "or paste the key above"}</span>
</div>
<div className="mt-5 border-t border-border-soft pt-5">
<Button type="button" variant="primary" loading={save.isPending} disabled={!blob.trim()} onClick={() => save.mutate()}>
{save.isPending ? "Checking…" : "Save licence"}
</Button>
</div>
</Card>
</Group>
{error && <p className="mt-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</p>}
<div className="mt-5 border-t border-border-soft pt-5">
<Button type="button" variant="primary" loading={save.isPending} disabled={!blob.trim()} onClick={() => save.mutate()}>
{save.isPending ? "Checking…" : "Save licence"}
</Button>
</div>
</Card>
</Group>
)}
</div>
</div>
+6 -4
View File
@@ -175,14 +175,16 @@ export default function StepsPage() {
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-3 text-text-secondary">
<button onClick={() => openEdit(s)} className="hover:text-text-primary">
Edit
{s.source === "default" ? "View" : "Edit"}
</button>
<a href={api.exportStepUrl(s.step_id)} download className="hover:text-text-primary">
Export
</a>
<button onClick={() => openEdit(s)} className="hover:text-danger">
Delete
</button>
{s.source !== "default" && (
<button onClick={() => openEdit(s)} className="hover:text-danger">
Delete
</button>
)}
</div>
</td>
</tr>
+9 -2
View File
@@ -457,7 +457,8 @@ export default function WorkflowBuilder() {
<div className="border-b border-border pb-4">
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
<textarea
className={`${inputClass} h-32 font-mono text-xs`}
className={`${inputClass} h-32 font-mono text-xs ${selectedLib?.source === "default" ? "cursor-not-allowed opacity-70" : ""}`}
readOnly={selectedLib?.source === "default"}
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
onChange={(e) =>
updateRef(selectedIdxInWf, {
@@ -466,7 +467,13 @@ export default function WorkflowBuilder() {
}
/>
<p className="mt-1 text-xs text-text-secondary">
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.
{selectedLib?.source === "default" ? (
<>This step ships with Vantage, so its script is fixed. Set its inputs below, or duplicate it into a shared step to change the script.</>
) : (
<>
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.
</>
)}
</p>
</div>
)}
+13 -17
View File
@@ -3,23 +3,19 @@ import "./globals.css";
import { Providers } from "@/components/Providers";
export const metadata: Metadata = {
title: "Vantage",
description: "Self-hosted SSH key management",
title: "Vantage",
description: "Self-hosted SSH key management",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// data-theme is what site/ and adminsite/ switch on. This app is locked to
// dark, so the attribute is fixed rather than toggleable — it is here so
// the three apps declare their ground the same way.
<html lang="en" className="dark" data-theme="dark">
<body className="bg-background font-sans text-text-primary">
<Providers>{children}</Providers>
</body>
</html>
);
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// data-theme is what site/ and adminsite/ switch on. This app is locked to
// dark, so the attribute is fixed rather than toggleable it is here so
// the three apps declare their ground the same way.
<html lang="en" className="dark" data-theme="dark">
<body className="bg-background font-sans text-text-primary">
<Providers>{children}</Providers>
</body>
</html>
);
}
+4 -8
View File
@@ -89,20 +89,16 @@ export default function SetupPage() {
<Card>
<p className="text-sm text-text-secondary">
{created.slug} has its own address, and sign-in is kept separate per instance. Continue to your instance&apos;s sign-in page and log in with the email and password
you just chose.
{created.slug} has its own address, and sign-in is kept separate per instance. Continue to your instance&apos;s sign-in page and log in with the email and password you just
chose.
</p>
<code className="mt-3 block overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">{created.loginUrl}</code>
<div className="mt-4 rounded border border-border p-3">
<p className="text-xs text-text-tertiary">Instance ID needed to activate a licence</p>
<p className="text-xs text-text-tertiary">Instance ID needed to activate a licence</p>
<div className="mt-1 flex items-center gap-2">
<code className="overflow-x-auto font-mono text-xs text-text-primary">{created.instanceId}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(created.instanceId)}
>
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(created.instanceId)}>
Copy
</button>
</div>
+3 -9
View File
@@ -31,7 +31,7 @@ interface Pulse {
/*
* Drawn from the shared tokens rather than read from them: this is canvas, so
* the values have to be literals. They are the light-theme accent falling to
* the app ground, with packets in the dark-theme accent the same two blues
* the app ground, with packets in the dark-theme accent the same two blues
* the marketing site's hero is built on.
*/
const BG_TOP = "#0b2a58"; // --accent, light
@@ -137,7 +137,7 @@ export function NetworkBackground() {
dir: Math.random() < 0.5 ? 1 : -1,
t: 0,
speed: 0.18 + Math.random() * 0.22, // ~2.5-5.5s per edge
delay: (initial ? Math.random() * 8 : Math.random() * 6),
delay: initial ? Math.random() * 8 : Math.random() * 6,
life: 0,
};
}
@@ -290,11 +290,5 @@ export function NetworkBackground() {
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none fixed inset-0 -z-10 h-full w-full"
/>
);
return <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 h-full w-full" />;
}
+4 -18
View File
@@ -133,7 +133,7 @@ const navItems: NavItem[] = [
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
];
/** Shared by the permanent aside and the offcanvas drawer one copy of the nav. */
/** Shared by the permanent aside and the offcanvas drawer one copy of the nav. */
export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const { user, instance, isAdmin } = useAuth();
@@ -159,9 +159,7 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
<Logo className="h-8 w-8 text-logo" />
<div className="min-w-0">
<span className="block text-base font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
{instance && (
<span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
)}
{instance && <span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>}
</div>
</div>
@@ -252,20 +250,8 @@ export function SidebarDrawer({ open, onClose }: { open: boolean; onClose: () =>
}, [open, onClose]);
return (
<div
className={clsx(
"fixed inset-0 z-50 lg:hidden",
open ? "visible" : "invisible pointer-events-none",
)}
>
<div
aria-hidden="true"
onClick={onClose}
className={clsx(
"absolute inset-0 bg-black/60 transition-opacity duration-200",
open ? "opacity-100" : "opacity-0",
)}
/>
<div className={clsx("fixed inset-0 z-50 lg:hidden", open ? "visible" : "invisible pointer-events-none")}>
<div aria-hidden="true" onClick={onClose} className={clsx("absolute inset-0 bg-black/60 transition-opacity duration-200", open ? "opacity-100" : "opacity-0")} />
<div
ref={panelRef}
id="app-sidebar-drawer"
+1 -1
View File
@@ -3,7 +3,7 @@ import { Card } from "@/components/ui";
/*
* Every block on the settings page is one of these: icon, title, one line of
* explanation, then the controls. Members and SSO adopted it when they moved
* off their own page a card that looked different would read as a different
* off their own page a card that looked different would read as a different
* kind of thing rather than another setting.
*/
export function SectionCard({
+22 -22
View File
@@ -3,9 +3,9 @@ import { clsx } from "clsx";
type Variant = "success" | "warning" | "danger" | "neutral" | "accent";
interface BadgeProps {
variant?: Variant;
children: React.ReactNode;
className?: string;
variant?: Variant;
children: React.ReactNode;
className?: string;
}
/*
@@ -13,31 +13,31 @@ interface BadgeProps {
* no fill. Every badge in this app holds one short state word, which is what
* the treatment is for.
*
* State is never carried by colour alone the word itself says it, and the
* State is never carried by colour alone the word itself says it, and the
* three state variants add a dot so the distinction survives a monochrome
* screen as well as it survives colour blindness.
*/
const variantClasses: Record<Variant, string> = {
success: "text-success",
warning: "text-warning",
danger: "text-danger",
neutral: "text-text-secondary",
accent: "text-accent",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
neutral: "text-text-secondary",
accent: "text-accent",
};
export function Badge({ variant = "neutral", children, className }: BadgeProps) {
const hasState = variant === "success" || variant === "warning" || variant === "danger";
const hasState = variant === "success" || variant === "warning" || variant === "danger";
return (
<span
className={clsx(
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-sm border border-current px-1.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-[0.08em]",
variantClasses[variant],
className
)}
>
{hasState && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{children}
</span>
);
return (
<span
className={clsx(
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-sm border border-current px-1.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-[0.08em]",
variantClasses[variant],
className,
)}
>
{hasState && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{children}
</span>
);
}
+73 -94
View File
@@ -4,7 +4,7 @@ import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
/*
* Below sm the table stops being a table: the head is hidden, each row becomes
* a bordered card and each cell becomes a label/value pair. That lives here
* rather than in the six pages that render tables two markup trees per page
* rather than in the six pages that render tables two markup trees per page
* would drift apart on the first edit, and every one of those trees would mean
* the same thing.
*
@@ -14,115 +14,94 @@ import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
*/
export function Table({ className, children, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="overflow-x-auto">
<table
className={clsx("w-full border-collapse text-sm max-sm:block", className)}
{...props}
>
{children}
</table>
</div>
);
return (
<div className="overflow-x-auto">
<table className={clsx("w-full border-collapse text-sm max-sm:block", className)} {...props}>
{children}
</table>
</div>
);
}
export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
{children}
</thead>
);
return (
<thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
{children}
</thead>
);
}
export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody
className={clsx(
"divide-y divide-border",
"max-sm:block max-sm:space-y-3 max-sm:divide-y-0 max-sm:p-3",
className
)}
{...props}
>
{children}
</tbody>
);
return (
<tbody className={clsx("divide-y divide-border", "max-sm:block max-sm:space-y-3 max-sm:divide-y-0 max-sm:p-3", className)} {...props}>
{children}
</tbody>
);
}
export function Tr({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={clsx(
"transition-colors hover:bg-surface-2/50",
"max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3",
className
)}
{...props}
>
{children}
</tr>
);
return (
<tr className={clsx("transition-colors hover:bg-surface-2/50", "max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3", className)} {...props}>
{children}
</tr>
);
}
export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={clsx(
// site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
// A column head is a key, not prose.
// text-secondary, not tertiary: a column head is how you navigate the
// table, and tertiary lands under 4.5:1 at this size.
"px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
className
)}
{...props}
>
{children}
</th>
);
return (
<th
className={clsx(
// site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
// A column head is a key, not prose.
// text-secondary, not tertiary: a column head is how you navigate the
// table, and tertiary lands under 4.5:1 at this size.
"px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
className,
)}
{...props}
>
{children}
</th>
);
}
interface TdProps extends TdHTMLAttributes<HTMLTableCellElement> {
/**
* The column head this cell belongs to, shown beside the value below sm
* where the real head is hidden. Omit on a trailing action cell an action
* needs no key, and the button then sits alone on its own row in the card.
*/
label?: string;
/**
* The column head this cell belongs to, shown beside the value below sm
* where the real head is hidden. Omit on a trailing action cell an action
* needs no key, and the button then sits alone on its own row in the card.
*/
label?: string;
}
export function Td({ className, label, children, ...props }: TdProps) {
return (
<td
className={clsx(
"px-4 py-3 text-text-primary",
"max-sm:flex max-sm:items-start max-sm:gap-4 max-sm:px-0 max-sm:py-1.5",
// Exactly one justify class clsx picks it. Emitting both and relying
// on string order would not work: Tailwind's output order decides which
// of two same-property utilities wins, not the order in this array.
label ? "max-sm:justify-between" : "max-sm:justify-end max-sm:pt-2.5",
className
)}
{...props}
>
{label && (
<span className="hidden shrink-0 font-mono text-[0.68rem] uppercase leading-5 tracking-[0.13em] text-text-secondary max-sm:inline">
{label}
</span>
)}
{/*
* Below sm the cell is a flex row, and a flex item's default
* min-width:auto resolves to its min-content width. A fingerprint or a
* target URL is a single unbreakable token, so min-content was the whole
* string: the value refused to shrink and spilled out of the card.
* min-w-0 lets it shrink, break-words gives the token somewhere to break.
*
* Above sm the wrapper is display:contents, so the desktop table renders
* exactly as it did before. Every other class here is max-sm: scoped on
* purpose display:contents generates no box, but inherited properties
* (overflow-wrap among them) would still reach the children.
*/}
<span className="max-sm:min-w-0 max-sm:break-words sm:contents">{children}</span>
</td>
);
return (
<td
className={clsx(
"px-4 py-3 text-text-primary",
"max-sm:flex max-sm:items-start max-sm:gap-4 max-sm:px-0 max-sm:py-1.5",
// Exactly one justify class clsx picks it. Emitting both and relying
// on string order would not work: Tailwind's output order decides which
// of two same-property utilities wins, not the order in this array.
label ? "max-sm:justify-between" : "max-sm:justify-end max-sm:pt-2.5",
className,
)}
{...props}
>
{label && <span className="hidden shrink-0 font-mono text-[0.68rem] uppercase leading-5 tracking-[0.13em] text-text-secondary max-sm:inline">{label}</span>}
{/*
* Below sm the cell is a flex row, and a flex item's default
* min-width:auto resolves to its min-content width. A fingerprint or a
* target URL is a single unbreakable token, so min-content was the whole
* string: the value refused to shrink and spilled out of the card.
* min-w-0 lets it shrink, break-words gives the token somewhere to break.
*
* Above sm the wrapper is display:contents, so the desktop table renders
* exactly as it did before. Every other class here is max-sm: scoped on
* purpose display:contents generates no box, but inherited properties
* (overflow-wrap among them) would still reach the children.
*/}
<span className="max-sm:min-w-0 max-sm:break-words sm:contents">{children}</span>
</td>
);
}
+25 -15
View File
@@ -17,8 +17,10 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Default steps are re-seeded from the image on every boot, so the server
// refuses to update or delete them. The form mirrors that rather than
// offering buttons that can only 409.
const locked = step?.source === "default";
const save = async () => {
setBusy(true); setError(null);
@@ -47,24 +49,30 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
};
return (
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
<Modal open={open} onClose={onClose} title={locked ? "Default step" : step ? "Edit base step" : "New step"} wide>
<div className="space-y-4">
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
<p className="text-xs text-text-secondary">Reusable steps are shared across all workflows. Editing here changes it everywhere.</p>
{locked ? (
<p className="rounded border border-border bg-surface-2 px-3 py-2 text-xs text-text-secondary">
This step ships with Vantage and is read-only. It is re-seeded on every upgrade. Export it and import a copy to make your own version.
</p>
) : (
<p className="text-xs text-text-secondary">Reusable steps are shared across all workflows. Editing here changes it everywhere.</p>
)}
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
<input className={inputClass} value={name} readOnly={locked} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
<select className={inputClass} value={interpreter} disabled={locked} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
<option value="bash">bash</option>
<option value="powershell">powershell</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(e.target.value)} />
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} readOnly={locked} onChange={(e) => setScript(e.target.value)} />
<p className="mt-1 text-xs text-text-secondary">Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.</p>
</div>
<div>
@@ -86,20 +94,22 @@ export function EditStepModal({ open, step, onClose }: { open: boolean; step: Wo
<div className="space-y-2">
{inputs.map((inp, i) => (
<div key={i} className="flex gap-2">
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}></Button>
<input className={inputClass} readOnly={locked} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
<input className={inputClass} readOnly={locked} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
<input className={inputClass} readOnly={locked} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
{!locked && <Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}></Button>}
</div>
))}
</div>
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
{!locked && (
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
)}
</div>
<div className="flex items-center justify-between pt-2">
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
{step && !locked ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
<Button variant="ghost" onClick={onClose}>{locked ? "Close" : "Cancel"}</Button>
{!locked && <Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>}
</div>
</div>
</div>
+33 -5
View File
@@ -6,7 +6,12 @@ export function openConsole(
container: HTMLElement,
wsUrl: string,
connectData = ""
): { disconnect: () => void; setScale: (scale: number) => void; resize: (width: number, height: number) => void } {
): {
disconnect: () => void;
setScale: (scale: number) => void;
resize: (width: number, height: number) => void;
focus: () => void;
} {
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
@@ -16,6 +21,7 @@ export function openConsole(
container.appendChild(client.getDisplay().getElement());
container.tabIndex = 0;
container.style.outline = "none";
client.connect(connectData);
@@ -48,18 +54,40 @@ export function openConsole(
keyboard.onkeyup = (k: number) => client.sendKeyEvent(0, k);
const refocus = () => container.focus();
container.addEventListener("mousedown", refocus);
container.focus();
const refocus = () => {
if (document.activeElement !== container) container.focus({ preventScroll: true });
};
container.addEventListener("pointerdown", refocus, true);
container.addEventListener("mousedown", refocus, true);
container.addEventListener("touchstart", refocus, true);
const onBlur = () => {
if (typeof keyboard.reset === "function") keyboard.reset();
};
container.addEventListener("blur", onBlur);
window.addEventListener("blur", onBlur);
container.focus({ preventScroll: true });
return {
disconnect() {
container.removeEventListener("mousedown", refocus);
container.removeEventListener("pointerdown", refocus, true);
container.removeEventListener("mousedown", refocus, true);
container.removeEventListener("touchstart", refocus, true);
container.removeEventListener("blur", onBlur);
window.removeEventListener("blur", onBlur);
keyboard.onkeydown = null;
keyboard.onkeyup = null;
if (typeof keyboard.reset === "function") keyboard.reset();
client.disconnect();
},
focus: refocus,
setScale(s: number) {
scale = s;
display.scale(s);
File diff suppressed because one or more lines are too long