+5
-2
@@ -15,8 +15,11 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Baked in at build time: NEXT_PUBLIC_* values are inlined into the client
|
||||
# bundle. SITE_API points at sitesvc, which serves both forms. Leave it empty
|
||||
# and contact falls back to mailto while signup reports it is unavailable.
|
||||
# bundle. SITE_API is the browser-reachable URL of sitesvc, which serves both
|
||||
# forms. It is effectively required: leave it empty and both forms report they
|
||||
# are not connected rather than submitting anywhere. Must be an origin the
|
||||
# browser can reach (not the internal sitesvc:8082) and be listed in sitesvc's
|
||||
# SITE_ORIGIN for CORS.
|
||||
ARG NEXT_PUBLIC_SITE_API=""
|
||||
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
|
||||
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
|
||||
|
||||
+46
-48
@@ -2,61 +2,59 @@ import type { Metadata } from "next";
|
||||
import { ContactForm } from "@/components/ContactForm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contact",
|
||||
description: "Sales questions, self-hosted licensing, security disclosures and bug reports.",
|
||||
title: "Contact",
|
||||
description: "Sales questions, self-hosted licensing, security disclosures and bug reports.",
|
||||
};
|
||||
|
||||
const CHANNELS = [
|
||||
{
|
||||
title: "Support",
|
||||
body: "Everything else, including anything urgent.",
|
||||
link: "support@hostxtra.co.uk",
|
||||
href: "mailto:support@hostxtra.co.uk",
|
||||
},
|
||||
{
|
||||
title: "Security disclosure",
|
||||
body: "Encrypted reports, acknowledged within 72 hours.",
|
||||
link: "support@hostxtra.co.uk",
|
||||
href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure",
|
||||
},
|
||||
{
|
||||
title: "Bugs and feature requests",
|
||||
body: "Public tracker, read by the people who write the code.",
|
||||
link: "git.vantage.sh/vantage",
|
||||
href: "https://git.vantage.sh/vantage",
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
body: "Control plane uptime and incident history.",
|
||||
link: "status.vantage.sh",
|
||||
href: "https://status.vantage.sh",
|
||||
},
|
||||
{
|
||||
title: "Support",
|
||||
body: "Everything else, including anything urgent.",
|
||||
link: "support@hostxtra.co.uk",
|
||||
href: "mailto:support@hostxtra.co.uk",
|
||||
},
|
||||
{
|
||||
title: "Security disclosure",
|
||||
body: "Encrypted reports, acknowledged within 72 hours.",
|
||||
link: "support@hostxtra.co.uk",
|
||||
href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure",
|
||||
},
|
||||
{
|
||||
title: "Bugs and feature requests",
|
||||
body: "Public 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.",
|
||||
link: "status.vantage.hostxtra.co.uk",
|
||||
href: "https://status.vantage.hostxtra.co.uk",
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
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>
|
||||
|
||||
<div className="split" style={{ marginTop: "2.4rem" }}>
|
||||
<div className="card">
|
||||
<ContactForm />
|
||||
</div>
|
||||
<div className="split" style={{ marginTop: "2.4rem" }}>
|
||||
<div className="card">
|
||||
<ContactForm />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="prose">Pick the right door and you will get a faster answer.</p>
|
||||
{CHANNELS.map((channel) => (
|
||||
<div className="chan" key={channel.title}>
|
||||
<h3>{channel.title}</h3>
|
||||
<p>{channel.body}</p>
|
||||
<a href={channel.href}>{channel.link}</a>
|
||||
<div>
|
||||
<p className="prose">Pick the right door and you will get a faster answer.</p>
|
||||
{CHANNELS.map((channel) => (
|
||||
<div className="chan" key={channel.title}>
|
||||
<h3>{channel.title}</h3>
|
||||
<p>{channel.body}</p>
|
||||
<a href={channel.href}>{channel.link}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+28
-30
@@ -5,37 +5,35 @@ import { Nav } from "@/components/Nav";
|
||||
import { ThemeScript } from "@/components/ThemeScript";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://vantage.sh"),
|
||||
title: {
|
||||
default: "Vantage — one control plane for the whole fleet",
|
||||
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.",
|
||||
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.",
|
||||
},
|
||||
icons: { icon: "/images/vantage_logo.svg" },
|
||||
metadataBase: new URL("https://vantage.hostxtra.co.uk"),
|
||||
title: {
|
||||
default: "Vantage — one control plane for the whole fleet",
|
||||
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.",
|
||||
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.",
|
||||
},
|
||||
icons: { icon: "/images/vantage_logo.svg" },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
<body>
|
||||
<a className="skip" href="#main">
|
||||
Skip to content
|
||||
</a>
|
||||
<Nav />
|
||||
<main id="main">{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
<body>
|
||||
<a className="skip" href="#main">
|
||||
Skip to content
|
||||
</a>
|
||||
<Nav />
|
||||
<main id="main">{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+165
-195
@@ -2,209 +2,179 @@ import Link from "next/link";
|
||||
import { InstrumentPanel } from "@/components/InstrumentPanel";
|
||||
|
||||
export default function OverviewPage() {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
<div className="hero__acts">
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Create your organisation
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<InstrumentPanel />
|
||||
|
||||
<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>
|
||||
<div className="split" style={{ marginTop: "2rem" }}>
|
||||
<p className="prose">
|
||||
Most small fleets end up with keys in a spreadsheet, scripts in someone's home directory, uptime checks
|
||||
in a separate service, secrets in a chat thread, and no record of who ran what. None of those systems know
|
||||
about each other, so every question — who can reach this box, what ran on it last, is it even up — gets
|
||||
answered by hand.
|
||||
</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>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
<div className="hero__acts">
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Create your organisation
|
||||
</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>
|
||||
</section>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
<ul>
|
||||
<li>fingerprint deduplication</li>
|
||||
<li>agent-side keypair generation</li>
|
||||
<li>revocation history preserved</li>
|
||||
</ul>
|
||||
</article>
|
||||
<InstrumentPanel />
|
||||
|
||||
<article className="cap">
|
||||
<span className="cap__k">Execution</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>
|
||||
<ul>
|
||||
<li>live streamed step logs</li>
|
||||
<li>stop, continue or retry on failure</li>
|
||||
<li>values passed between steps</li>
|
||||
</ul>
|
||||
</article>
|
||||
<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>
|
||||
<div className="split" style={{ marginTop: "2rem" }}>
|
||||
<p className="prose">
|
||||
Most small fleets end up with keys in a spreadsheet, scripts in someone's home directory, uptime checks in a separate service, secrets in a chat thread, and no record of
|
||||
who ran what. None of those systems know about each other, so every question — who can reach this box, what ran on it last, is it even up — gets answered by hand.
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<ul>
|
||||
<li>incidents and uptime history</li>
|
||||
<li>certificate expiry warnings</li>
|
||||
<li>alerts to five channel types</li>
|
||||
</ul>
|
||||
</article>
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
<ul>
|
||||
<li>fingerprint deduplication</li>
|
||||
<li>agent-side keypair generation</li>
|
||||
<li>revocation history preserved</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="cap">
|
||||
<span className="cap__k">Secrets</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>
|
||||
<ul>
|
||||
<li>read token for External Secrets Operator</li>
|
||||
<li>rotatable, hashed at rest</li>
|
||||
<li>reveal is an audited action</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article className="cap">
|
||||
<span className="cap__k">Execution</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>
|
||||
<ul>
|
||||
<li>live streamed step logs</li>
|
||||
<li>stop, continue or retry on failure</li>
|
||||
<li>values passed between steps</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>
|
||||
<ul>
|
||||
<li>one-time session tokens</li>
|
||||
<li>credentials consumed on connect</li>
|
||||
<li>no client software</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>
|
||||
<ul>
|
||||
<li>incidents and uptime history</li>
|
||||
<li>certificate expiry warnings</li>
|
||||
<li>alerts to five channel types</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>
|
||||
<ul>
|
||||
<li>metrics every 30 seconds</li>
|
||||
<li>one-click package updates</li>
|
||||
<li>agents update themselves</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<article className="cap">
|
||||
<span className="cap__k">Secrets</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>
|
||||
<ul>
|
||||
<li>read token for External Secrets Operator</li>
|
||||
<li>rotatable, hashed at rest</li>
|
||||
<li>reveal is an audited action</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<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>
|
||||
<div className="flow">
|
||||
<div className="flow__c">
|
||||
<span className="flow__n">FIRST</span>
|
||||
<h3>Create an organisation</h3>
|
||||
<p>You become the owner. Everything inside is invisible to every other organisation.</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<ul>
|
||||
<li>one-time session tokens</li>
|
||||
<li>credentials consumed on connect</li>
|
||||
<li>no client software</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<pre className="code" style={{ marginTop: "1.6rem" }}>
|
||||
<i># Linux</i>
|
||||
{"\n"}
|
||||
<b>curl</b> -fsSL https://vantage.sh/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
|
||||
{"\n\n"}
|
||||
<i># Windows</i>
|
||||
{"\n"}
|
||||
<b>irm</b> https://vantage.sh/install.ps1 | <b>iex</b>
|
||||
</pre>
|
||||
</section>
|
||||
<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>
|
||||
<ul>
|
||||
<li>metrics every 30 seconds</li>
|
||||
<li>one-click package updates</li>
|
||||
<li>agents update themselves</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rail band band--flush">
|
||||
<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 organisation, install one agent, and watch a key land on a real box.
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Create organisation
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
<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>
|
||||
<div className="flow">
|
||||
<div className="flow__c">
|
||||
<span className="flow__n">FIRST</span>
|
||||
<h3>Create an organisation</h3>
|
||||
<p>You become the owner. Everything inside is invisible to every other organisation.</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className="code" style={{ marginTop: "1.6rem" }}>
|
||||
<i># Linux</i>
|
||||
{"\n"}
|
||||
<b>curl</b> -fsSL https://vantage.hostxtra.co.uk/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
|
||||
{"\n\n"}
|
||||
<i># Windows</i>
|
||||
{"\n"}
|
||||
<b>irm</b> https://vantage.hostxtra.co.uk/install.ps1 | <b>iex</b>
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="rail band band--flush">
|
||||
<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 organisation, install one agent, and watch a key land on a real box.</p>
|
||||
</div>
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Create organisation
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+58
-64
@@ -2,74 +2,68 @@ import type { Metadata } from "next";
|
||||
import { OrgForm } from "@/components/OrgForm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create organisation",
|
||||
description:
|
||||
"An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
|
||||
title: "Create organisation",
|
||||
description: "An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
|
||||
};
|
||||
|
||||
export default function StartPage() {
|
||||
return (
|
||||
<section className="rail band band--open">
|
||||
<div className="split">
|
||||
<div>
|
||||
<span className="tag">Create organisation</span>
|
||||
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>
|
||||
Set up your organisation.
|
||||
</h1>
|
||||
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
|
||||
An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any
|
||||
other organisation. Confirm your email and it is created with you as its owner.
|
||||
</p>
|
||||
return (
|
||||
<section className="rail band band--open">
|
||||
<div className="split">
|
||||
<div>
|
||||
<span className="tag">Create organisation</span>
|
||||
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Set up your organisation.</h1>
|
||||
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
|
||||
An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other organisation. Confirm your email and it is created with you
|
||||
as its owner.
|
||||
</p>
|
||||
|
||||
<div className="card" style={{ marginTop: "1.9rem" }}>
|
||||
<OrgForm />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ marginTop: "1.9rem" }}>
|
||||
<OrgForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="tag">What happens next</span>
|
||||
<div className="specs">
|
||||
<div className="spec">
|
||||
<span className="spec__k">FIRST</span>
|
||||
<div>
|
||||
<h3>Confirm your email</h3>
|
||||
<p>
|
||||
We send a link that works once. Your organisation is created when you open it, not before.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">THEN</span>
|
||||
<div>
|
||||
<h3>Add a key</h3>
|
||||
<p>
|
||||
Paste the contents of <code>~/.ssh/id_ed25519.pub</code>. Vantage fingerprints it and refuses
|
||||
duplicates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">THEN</span>
|
||||
<div>
|
||||
<h3>Add a server</h3>
|
||||
<p>Run the install command as root. It expires in an hour and works once.</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="tag">What happens next</span>
|
||||
<div className="specs">
|
||||
<div className="spec">
|
||||
<span className="spec__k">FIRST</span>
|
||||
<div>
|
||||
<h3>Confirm your email</h3>
|
||||
<p>We send a link that works once. Your organisation is created when you open it, not before.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">THEN</span>
|
||||
<div>
|
||||
<h3>Add a key</h3>
|
||||
<p>
|
||||
Paste the contents of <code>~/.ssh/id_ed25519.pub</code>. Vantage fingerprints it and refuses duplicates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">THEN</span>
|
||||
<div>
|
||||
<h3>Add a server</h3>
|
||||
<p>Run the install command as root. It expires in an hour and works once.</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className="code" style={{ marginTop: "1.8rem" }}>
|
||||
<b>curl</b> -fsSL https://vantage.sh/install | \{"\n"}
|
||||
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
<pre className="code" style={{ marginTop: "1.8rem" }}>
|
||||
<b>curl</b> -fsSL https://vantage.hostxtra.co.uk/install | \{"\n"}
|
||||
{" "}bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+85
-103
@@ -7,114 +7,96 @@ import { submitSignup, type SubmitResult } from "@/lib/submit";
|
||||
const MIN_PASSWORD = 12;
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function OrgForm() {
|
||||
const [slug, setSlug] = useState("");
|
||||
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
|
||||
const sending = result.state === "sending";
|
||||
const [slug, setSlug] = useState("");
|
||||
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
|
||||
const sending = result.state === "sending";
|
||||
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
setResult({ state: "sending" });
|
||||
setResult(
|
||||
await submitSignup({
|
||||
org_name: String(data.get("org_name") ?? ""),
|
||||
email: String(data.get("email") ?? ""),
|
||||
password: String(data.get("password") ?? ""),
|
||||
website: String(data.get("website") ?? ""),
|
||||
})
|
||||
);
|
||||
}
|
||||
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
setResult({ state: "sending" });
|
||||
setResult(
|
||||
await submitSignup({
|
||||
org_name: String(data.get("org_name") ?? ""),
|
||||
email: String(data.get("email") ?? ""),
|
||||
password: String(data.get("password") ?? ""),
|
||||
website: String(data.get("website") ?? ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (result.state === "sent") {
|
||||
return (
|
||||
<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 <b>{slug || "your organisation"}</b> is created with you as its owner. The link works once and expires in 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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldError = (name: string) => result.fields?.[name];
|
||||
|
||||
if (result.state === "sent") {
|
||||
return (
|
||||
<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 <b>{slug || "your organisation"}</b> is created with you as its
|
||||
owner. The link works once and expires in 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.
|
||||
</p>
|
||||
</div>
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<Honeypot />
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-org">Organisation name</label>
|
||||
<input id="o-org" name="org_name" type="text" placeholder="Northgate Systems" required onChange={(e) => setSlug(slugify(e.target.value))} aria-describedby="o-org-err" />
|
||||
<span className="hostline">
|
||||
<b>{slug || "your-org"}</b>.vantage.hostxtra.co.uk
|
||||
</span>
|
||||
{fieldError("org_name") && (
|
||||
<small id="o-org-err" className="field__err">
|
||||
{fieldError("org_name")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-email">Owner email</label>
|
||||
<input id="o-email" name="email" type="email" autoComplete="email" required aria-describedby="o-email-err" />
|
||||
<small>You become the first owner and can invite the rest of the team afterwards.</small>
|
||||
{fieldError("email") && (
|
||||
<small id="o-email-err" className="field__err">
|
||||
{fieldError("email")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-pass">Password</label>
|
||||
<input id="o-pass" name="password" type="password" autoComplete="new-password" minLength={MIN_PASSWORD} required aria-describedby="o-pass-err" />
|
||||
<small>At least {MIN_PASSWORD} characters. Use a manager — you are about to manage SSH keys with it.</small>
|
||||
{fieldError("password") && (
|
||||
<small id="o-pass-err" className="field__err">
|
||||
{fieldError("password")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
|
||||
{sending ? "Sending…" : "Create organisation"}
|
||||
</button>
|
||||
|
||||
{result.state === "error" && result.message && (
|
||||
<p className="field__err" role="alert">
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldError = (name: string) => result.fields?.[name];
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<Honeypot />
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-org">Organisation name</label>
|
||||
<input
|
||||
id="o-org"
|
||||
name="org_name"
|
||||
type="text"
|
||||
placeholder="Northgate Systems"
|
||||
required
|
||||
onChange={(e) => setSlug(slugify(e.target.value))}
|
||||
aria-describedby="o-org-err"
|
||||
/>
|
||||
<span className="hostline">
|
||||
<b>{slug || "your-org"}</b>.vantage.sh
|
||||
</span>
|
||||
{fieldError("org_name") && (
|
||||
<small id="o-org-err" className="field__err">
|
||||
{fieldError("org_name")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-email">Owner email</label>
|
||||
<input id="o-email" name="email" type="email" autoComplete="email" required aria-describedby="o-email-err" />
|
||||
<small>You become the first owner and can invite the rest of the team afterwards.</small>
|
||||
{fieldError("email") && (
|
||||
<small id="o-email-err" className="field__err">
|
||||
{fieldError("email")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="o-pass">Password</label>
|
||||
<input
|
||||
id="o-pass"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={MIN_PASSWORD}
|
||||
required
|
||||
aria-describedby="o-pass-err"
|
||||
/>
|
||||
<small>At least {MIN_PASSWORD} characters. Use a manager — you are about to manage SSH keys with it.</small>
|
||||
{fieldError("password") && (
|
||||
<small id="o-pass-err" className="field__err">
|
||||
{fieldError("password")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
|
||||
{sending ? "Sending…" : "Create organisation"}
|
||||
</button>
|
||||
|
||||
{result.state === "error" && result.message && (
|
||||
<p className="field__err" role="alert">
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
+38
-58
@@ -14,74 +14,54 @@ const FALLBACK_ADDRESS = process.env.NEXT_PUBLIC_CONTACT_EMAIL ?? "support@hostx
|
||||
export type SubmitState = "idle" | "sending" | "sent" | "error";
|
||||
|
||||
export type SubmitResult = {
|
||||
state: SubmitState;
|
||||
/** Message to show when the submission was refused. */
|
||||
message?: string;
|
||||
/** Per-field messages, keyed by field name. */
|
||||
fields?: Record<string, string>;
|
||||
state: SubmitState;
|
||||
/** Message to show when the submission was refused. */
|
||||
message?: string;
|
||||
/** Per-field messages, keyed by field name. */
|
||||
fields?: Record<string, string>;
|
||||
};
|
||||
|
||||
type FieldProblem = { field: string; message: string };
|
||||
|
||||
async function post(url: string, payload: unknown): Promise<SubmitResult> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) return { state: "sent" };
|
||||
if (res.ok) return { state: "sent" };
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
const problems: FieldProblem[] = data?.fields ?? [];
|
||||
const data = await res.json().catch(() => null);
|
||||
const problems: FieldProblem[] = data?.fields ?? [];
|
||||
|
||||
return {
|
||||
state: "error",
|
||||
message: data?.error ?? "That did not go through. Try again in a moment.",
|
||||
fields: Object.fromEntries(problems.map((p) => [p.field, p.message])),
|
||||
};
|
||||
} catch {
|
||||
return { state: "error", message: "We could not reach the server. Check your connection and try again." };
|
||||
}
|
||||
return {
|
||||
state: "error",
|
||||
message: data?.error ?? "That did not go through. Try again in a moment.",
|
||||
fields: Object.fromEntries(problems.map((p) => [p.field, p.message])),
|
||||
};
|
||||
} catch {
|
||||
return { state: "error", message: "We could not reach the server. Check your connection and try again." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitContact(fields: {
|
||||
name: string;
|
||||
email: string;
|
||||
servers: string;
|
||||
topic: string;
|
||||
message: string;
|
||||
website: string;
|
||||
}): Promise<SubmitResult> {
|
||||
if (!SITE_API) {
|
||||
const body = [
|
||||
`Name: ${fields.name}`,
|
||||
`Email: ${fields.email}`,
|
||||
`Servers: ${fields.servers}`,
|
||||
`Topic: ${fields.topic}`,
|
||||
"",
|
||||
fields.message,
|
||||
].join("\n");
|
||||
window.location.href = `mailto:${FALLBACK_ADDRESS}?subject=${encodeURIComponent(
|
||||
`Vantage enquiry — ${fields.topic}`
|
||||
)}&body=${encodeURIComponent(body)}`;
|
||||
return { state: "sent" };
|
||||
}
|
||||
return post(`${SITE_API}/api/contact`, fields);
|
||||
export async function submitContact(fields: { name: string; email: string; servers: string; topic: string; message: string; website: string }): Promise<SubmitResult> {
|
||||
if (!SITE_API) {
|
||||
return {
|
||||
state: "error",
|
||||
message: `The contact form is not connected yet. Email ${FALLBACK_ADDRESS} directly.`,
|
||||
};
|
||||
}
|
||||
return post(`${SITE_API}/api/contact`, fields);
|
||||
}
|
||||
|
||||
export async function submitSignup(fields: {
|
||||
org_name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
website: string;
|
||||
}): Promise<SubmitResult> {
|
||||
if (!SITE_API) {
|
||||
return {
|
||||
state: "error",
|
||||
message: `Signup is not available from here yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
|
||||
};
|
||||
}
|
||||
return post(`${SITE_API}/api/signup`, fields);
|
||||
export async function submitSignup(fields: { org_name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
|
||||
if (!SITE_API) {
|
||||
return {
|
||||
state: "error",
|
||||
message: `Signup is not available from here yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
|
||||
};
|
||||
}
|
||||
return post(`${SITE_API}/api/signup`, fields);
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Reference in New Issue
Block a user