@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.env*
|
||||
npm-debug.log*
|
||||
@@ -0,0 +1,48 @@
|
||||
# Dependencies stage
|
||||
FROM node:26-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Build stage
|
||||
FROM node:26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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.
|
||||
ARG NEXT_PUBLIC_SITE_API=""
|
||||
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
|
||||
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
|
||||
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:26-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,131 @@
|
||||
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.",
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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'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>
|
||||
<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>
|
||||
</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 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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.",
|
||||
};
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Footer } from "@/components/Footer";
|
||||
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" },
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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>
|
||||
</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>
|
||||
|
||||
<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">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">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">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">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">
|
||||
<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.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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Platform",
|
||||
description:
|
||||
"How Vantage fits together: a control plane you run, one agent per server, and a single outbound connection between them.",
|
||||
};
|
||||
|
||||
export default function PlatformPage() {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<div className="split" style={{ marginTop: "3rem" }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: "var(--s-2)", maxWidth: "18ch" }}>The agent never listens.</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.
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="specs specs--flush">
|
||||
<div className="spec">
|
||||
<span className="spec__k">POLL</span>
|
||||
<div>
|
||||
<h3>SyncKeys, every 30s</h3>
|
||||
<p>The desired key set for this server. Unchanged state means no disk write at all.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">PUSH</span>
|
||||
<div>
|
||||
<h3>Command stream</h3>
|
||||
<p>Generate a key, run a step, apply updates, update the agent, clean up a workspace.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">REPORT</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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" }}>
|
||||
<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.
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section className="rail band">
|
||||
<span className="tag">Tenancy and identity</span>
|
||||
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>
|
||||
Organisations are the boundary.
|
||||
</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 organisation, and every lookup is filtered
|
||||
by it. Uniqueness constraints are enforced by the database, not by application logic.
|
||||
</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>
|
||||
</article>
|
||||
<article className="cap">
|
||||
<span className="cap__k">Identity</span>
|
||||
<h3>Local or OIDC, per organisation</h3>
|
||||
<p>
|
||||
Sign in with email and password, or connect your own provider. Each organisation configures its own issuer
|
||||
and client.
|
||||
</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>
|
||||
</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>
|
||||
<div className="specs" style={{ maxWidth: "70ch" }}>
|
||||
<div className="spec">
|
||||
<span className="spec__k">NOT A PROXY</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">NO CUSTODY</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">NO PER-USER</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">NO PLUGINS</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
description:
|
||||
"Priced per managed server. People, keys, workflows and secrets are free. Free for 3 servers, £4 per server per month, or £290 a year self-hosted.",
|
||||
};
|
||||
|
||||
const COMPARISON: [string, string, string, string][] = [
|
||||
["Managed servers", "3", "Unlimited", "Unlimited"],
|
||||
["Members", "1", "Unlimited", "Unlimited"],
|
||||
["SSH key assignment", "Yes", "Yes", "Yes"],
|
||||
["Workflows and step library", "Yes", "Yes", "Yes"],
|
||||
["Monitors", "3", "Unlimited", "Unlimited"],
|
||||
["Secrets vault", "—", "Yes", "Yes"],
|
||||
["Browser console", "—", "Yes", "Yes"],
|
||||
["OIDC single sign-on", "—", "Yes", "Yes"],
|
||||
["Audit history", "30 days", "Forever", "Forever"],
|
||||
["Runs on your hardware", "—", "—", "Yes"],
|
||||
["Support", "Community", "Next business day", "Priority"],
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<section className="rail band band--open">
|
||||
<span className="tag">Pricing</span>
|
||||
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "17ch" }}>
|
||||
Per managed server. Nothing else counts.
|
||||
</h1>
|
||||
<p className="lede">
|
||||
People are free. Keys, workflows, monitors and secrets are free. You pay for servers running an agent, because
|
||||
that is the only number that grows with you.
|
||||
</p>
|
||||
|
||||
<div className="plans">
|
||||
<div className="plan">
|
||||
<div>
|
||||
<div className="plan__n">Solo</div>
|
||||
<p className="plan__d">A homelab, a couple of VPSes, and the keys on your own laptop.</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£0 <span>forever</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Up to 3 servers</li>
|
||||
<li>Keys, workflows and monitors</li>
|
||||
<li>One member, one organisation</li>
|
||||
<li>Community support</li>
|
||||
</ul>
|
||||
<Link className="btn btn--line" href="/start">
|
||||
Create organisation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="plan plan--pick">
|
||||
<div>
|
||||
<div className="plan__n">Fleet</div>
|
||||
<p className="plan__d">Real infrastructure, and more than one person holding the keys.</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£4 <span>/ server / month</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Unlimited servers and members</li>
|
||||
<li>Owner, admin and member roles</li>
|
||||
<li>OIDC single sign-on</li>
|
||||
<li>Browser console and secrets vault</li>
|
||||
<li>Full audit history</li>
|
||||
<li>Email support, next business day</li>
|
||||
</ul>
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Start 14-day trial
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="plan">
|
||||
<div>
|
||||
<div className="plan__n">Self-hosted</div>
|
||||
<p className="plan__d">The whole stack on your metal, behind your own boundary.</p>
|
||||
</div>
|
||||
<div className="plan__p">
|
||||
£290 <span>/ year, per install</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Everything in Fleet, no server cap</li>
|
||||
<li>Your MongoDB, Redis and certificates</li>
|
||||
<li>Mirror agent releases internally</li>
|
||||
<li>Priority support and upgrade notes</li>
|
||||
</ul>
|
||||
<Link className="btn btn--line" href="/contact">
|
||||
Talk to us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scroll">
|
||||
<table className="cmp">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>Solo</th>
|
||||
<th>Fleet</th>
|
||||
<th>Self-hosted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON.map(([capability, solo, fleet, selfHosted]) => (
|
||||
<tr key={capability}>
|
||||
<td>{capability}</td>
|
||||
<td>{solo}</td>
|
||||
<td>{fleet}</td>
|
||||
<td>{selfHosted}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "3.2rem", maxWidth: "66ch" }}>
|
||||
<span className="tag">Fine print, in plain words</span>
|
||||
<div className="specs">
|
||||
<div className="spec">
|
||||
<span className="spec__k">COUNTING</span>
|
||||
<div>
|
||||
<h3>What counts as a server</h3>
|
||||
<p>One running agent, one server. Remove a box and it stops billing that day.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">LIMITS</span>
|
||||
<div>
|
||||
<h3>Going over on Solo</h3>
|
||||
<p>
|
||||
Nothing is deleted. A fourth agent registers and heartbeats, but stops syncing keys until you upgrade or
|
||||
remove a server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="spec">
|
||||
<span className="spec__k">EXIT</span>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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.",
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.0 MiB |
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Honeypot } from "@/components/Honeypot";
|
||||
import { submitContact, type SubmitResult } from "@/lib/submit";
|
||||
|
||||
export function ContactForm() {
|
||||
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 submitContact({
|
||||
name: String(data.get("name") ?? ""),
|
||||
email: String(data.get("email") ?? ""),
|
||||
servers: String(data.get("servers") ?? ""),
|
||||
topic: String(data.get("topic") ?? ""),
|
||||
message: String(data.get("message") ?? ""),
|
||||
website: String(data.get("website") ?? ""),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (result.state === "sent") {
|
||||
return (
|
||||
<div role="status">
|
||||
<h2 style={{ fontSize: "var(--s-1)" }}>Message sent.</h2>
|
||||
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
|
||||
We reply within one business day. If it is urgent, email support@hostxtra.co.uk directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldError = (name: string) => result.fields?.[name];
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<Honeypot />
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-name">Your name</label>
|
||||
<input id="c-name" name="name" type="text" autoComplete="name" required aria-describedby="c-name-err" />
|
||||
{fieldError("name") && (
|
||||
<small id="c-name-err" className="field__err">
|
||||
{fieldError("name")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-email">Work email</label>
|
||||
<input id="c-email" name="email" type="email" autoComplete="email" required aria-describedby="c-email-err" />
|
||||
{fieldError("email") && (
|
||||
<small id="c-email-err" className="field__err">
|
||||
{fieldError("email")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-servers">Roughly how many servers?</label>
|
||||
<select id="c-servers" name="servers" defaultValue="4–25">
|
||||
<option>1–3</option>
|
||||
<option>4–25</option>
|
||||
<option>26–100</option>
|
||||
<option>More than 100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-topic">What is this about?</label>
|
||||
<select id="c-topic" name="topic" defaultValue="Evaluating Vantage">
|
||||
<option>Evaluating Vantage</option>
|
||||
<option>Self-hosted licensing</option>
|
||||
<option>Migrating from something else</option>
|
||||
<option>Security disclosure</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="c-message">What are you trying to solve?</label>
|
||||
<textarea
|
||||
id="c-message"
|
||||
name="message"
|
||||
required
|
||||
placeholder="We inherit client servers and can never prove who still has access…"
|
||||
aria-describedby="c-message-err"
|
||||
/>
|
||||
{fieldError("message") && (
|
||||
<small id="c-message-err" className="field__err">
|
||||
{fieldError("message")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn btn--solid" type="submit" style={{ alignSelf: "flex-start" }} disabled={sending}>
|
||||
{sending ? "Sending…" : "Send message"}
|
||||
</button>
|
||||
|
||||
{result.state === "error" && result.message && (
|
||||
<p className="field__err" role="alert">
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { NAV_LINKS } from "@/components/nav-links";
|
||||
|
||||
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>
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link href="/start">Create organisation</Link>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* A field no person ever sees or tabs into, but an automated form-filler will
|
||||
* happily complete. The server treats any value here as a bot. Hidden with
|
||||
* inline styles rather than a utility class so it stays hidden even if the
|
||||
* stylesheet fails to load.
|
||||
*/
|
||||
export function Honeypot() {
|
||||
return (
|
||||
<div style={{ position: "absolute", left: "-9999px", width: 1, height: 1, overflow: "hidden" }} aria-hidden="true">
|
||||
<label htmlFor="website">Website</label>
|
||||
<input id="website" name="website" type="text" tabIndex={-1} autoComplete="off" defaultValue="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/*
|
||||
* The hero's signature element: a fleet panel that plays one honest cycle of
|
||||
* what the product actually does — a workflow runs three steps, a TLS monitor
|
||||
* fails and opens an incident, a key revocation lands — then rests. It is a
|
||||
* dramatisation, not live data, so nothing here talks to an API.
|
||||
*/
|
||||
|
||||
type LogLine = { time: string; body: React.ReactNode };
|
||||
|
||||
type Beat = {
|
||||
at: number;
|
||||
line: LogLine;
|
||||
effect?: "incident" | "runDone" | "revoked";
|
||||
};
|
||||
|
||||
const BEATS: Beat[] = [
|
||||
{ at: 600, line: { time: "14:22:02", body: "running · step 1/3 · pull image" } },
|
||||
{ at: 1500, line: { time: "14:22:04", body: "running · step 2/3 · migrate database" } },
|
||||
{
|
||||
at: 2600,
|
||||
line: { time: "14:22:07", body: <><span className="ok">ok</span> · migrate database · exit 0</> },
|
||||
},
|
||||
{
|
||||
at: 3400,
|
||||
line: { time: "14:22:08", body: "running · step 3/3 · restart service" },
|
||||
effect: "incident",
|
||||
},
|
||||
{
|
||||
at: 4300,
|
||||
line: { time: "14:22:10", body: <><span className="er">monitor</span> · edge-gw-02 tls · connection refused</> },
|
||||
},
|
||||
{
|
||||
at: 5200,
|
||||
line: { time: "14:22:11", body: <><span className="ok">ok</span> · restart service · exit 0</> },
|
||||
effect: "runDone",
|
||||
},
|
||||
{
|
||||
at: 6000,
|
||||
line: { time: "14:22:12", body: <>run finished · <span className="ok">success</span> · 3 steps · 1 server</> },
|
||||
effect: "revoked",
|
||||
},
|
||||
];
|
||||
|
||||
const FIRST_LINE: LogLine = { time: "14:22:01", body: "queued · deploy-app · 1 server" };
|
||||
const MAX_LINES = 7;
|
||||
|
||||
export function InstrumentPanel() {
|
||||
const [lines, setLines] = useState<LogLine[]>([FIRST_LINE]);
|
||||
const [incident, setIncident] = useState(false);
|
||||
const [runActive, setRunActive] = useState(true);
|
||||
const [revoked, setRevoked] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const apply = (effect: Beat["effect"]) => {
|
||||
if (effect === "incident") setIncident(true);
|
||||
if (effect === "runDone") setRunActive(false);
|
||||
if (effect === "revoked") setRevoked(true);
|
||||
};
|
||||
|
||||
// Reduced motion gets the finished state immediately rather than no state.
|
||||
if (reduced) {
|
||||
setLines([FIRST_LINE, ...BEATS.map((b) => b.line)].slice(-MAX_LINES));
|
||||
BEATS.forEach((b) => apply(b.effect));
|
||||
setResting(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timers = BEATS.map((beat) =>
|
||||
window.setTimeout(() => {
|
||||
setLines((prev) => [...prev, beat.line].slice(-MAX_LINES));
|
||||
apply(beat.effect);
|
||||
}, beat.at)
|
||||
);
|
||||
timers.push(window.setTimeout(() => setResting(true), 6600));
|
||||
|
||||
return () => timers.forEach(window.clearTimeout);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="instrument">
|
||||
<div className="rail">
|
||||
<div className="instrument__bar">
|
||||
<span>
|
||||
<b>northgate</b> · fleet
|
||||
</span>
|
||||
<span>12 servers</span>
|
||||
<span>{incident ? "10 up" : "11 up"}</span>
|
||||
<span>{incident ? "2 down" : "1 down"}</span>
|
||||
<span>3 monitors</span>
|
||||
<span>{runActive ? "1 run active" : "no runs active"}</span>
|
||||
<span className="instrument__clock">14:22:12 UTC</span>
|
||||
</div>
|
||||
|
||||
<div className="panes">
|
||||
<section className="pane" aria-label="Fleet status">
|
||||
<h2 className="pane__h">
|
||||
Fleet <span>{revoked ? "key revoked · 1 server updated" : "agents polling"}</span>
|
||||
</h2>
|
||||
|
||||
<Row host="proxmox-node-1" sub="4 keys · 12% cpu" state="up" label="Active" />
|
||||
<Row host="db-primary" sub="3 keys · 61% cpu" state="up" label="Active" />
|
||||
<Row
|
||||
host="edge-gw-02"
|
||||
sub={incident ? "2 keys · tls refused" : "2 keys · tls 41d"}
|
||||
state={incident ? "down" : "up"}
|
||||
label={incident ? "Incident" : "Active"}
|
||||
/>
|
||||
<Row host="win-build-01" sub="agent 1.4.1 · update ready" state="pend" label="Pending" />
|
||||
<Row
|
||||
host="app-worker-03"
|
||||
sub={revoked ? "4 keys · 1 revoked" : "5 keys · idle"}
|
||||
state="up"
|
||||
label="Active"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="pane" aria-label="Workflow run">
|
||||
<h2 className="pane__h">
|
||||
Run <span>run_8f31c2</span>
|
||||
</h2>
|
||||
<div className="stream" aria-live="polite">
|
||||
{lines.map((line, i) => (
|
||||
<div key={`${line.time}-${i}`}>
|
||||
<span className="t">{line.time}</span> {line.body}
|
||||
</div>
|
||||
))}
|
||||
{resting && (
|
||||
<div>
|
||||
<span className="caret">_</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
host,
|
||||
sub,
|
||||
state,
|
||||
label,
|
||||
}: {
|
||||
host: string;
|
||||
sub: string;
|
||||
state: "up" | "down" | "pend";
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="frow">
|
||||
<span className={state === "up" ? "dot" : `dot dot--${state}`} />
|
||||
<span className="frow__host">{host}</span>
|
||||
<span className="frow__sub">{sub}</span>
|
||||
<span className={`chip chip--${state}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Traced from web/public/images/vantage_logo.svg. The fill is currentColor so
|
||||
// the mark follows the --logo token in both themes.
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="246 207 533 610" className={className} aria-hidden="true" focusable="false">
|
||||
<g transform="translate(0,1024) scale(0.1,-0.1)" fill="currentColor" stroke="none">
|
||||
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187 -702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559 1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459 272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84 -1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297 -331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0 -1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204 -447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256 -13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333 805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432 -256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177 0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177 -2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157 -95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39 3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8 -75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165 52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397 -233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185 -321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471 483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160 160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73 -158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838 l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6 -710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79 46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147 -86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10 708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107 c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590 -6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365 l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393 -194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136 -292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548 -5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188 -112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3 178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73 -40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417 -247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116 343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6 -292 176 -301 174 -2 0 -24 -13 -49 -28z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { NAV_LINKS } from "@/components/nav-links";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
|
||||
export function Nav() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// A route change should never leave the drawer hanging open behind the new page.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
function current(href: string) {
|
||||
return pathname === href || pathname === `${href}/` ? "page" : undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="nav">
|
||||
<div className="rail nav__in">
|
||||
<Link className="brand" href="/">
|
||||
<Logo />
|
||||
<b>Vantage</b>
|
||||
</Link>
|
||||
|
||||
<nav className="nav__links" aria-label="Main">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn nav__menu"
|
||||
aria-expanded={open}
|
||||
aria-controls="nav-drawer"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? "Close" : "Menu"}
|
||||
</button>
|
||||
|
||||
<Link className="btn btn--solid btn--sm nav__cta" href="/start">
|
||||
Create organisation
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{open && (
|
||||
<div className="drawer" id="nav-drawer">
|
||||
<div className="rail">
|
||||
<nav aria-label="Main, mobile">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} aria-current={current(link.href)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<Link className="btn btn--solid" href="/start">
|
||||
Create organisation
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Honeypot } from "@/components/Honeypot";
|
||||
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, "");
|
||||
}
|
||||
|
||||
export function OrgForm() {
|
||||
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") ?? ""),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Applies the stored theme before first paint. Without this the page renders in
|
||||
// the OS theme for a frame and then snaps to the stored one.
|
||||
const script = `
|
||||
(function(){
|
||||
try {
|
||||
var t = localStorage.getItem("vantage-theme");
|
||||
if (t === "light" || t === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export function ThemeScript() {
|
||||
return <script dangerouslySetInnerHTML={{ __html: script }} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "vantage-theme";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
function currentTheme(): Theme {
|
||||
const set = document.documentElement.getAttribute("data-theme");
|
||||
if (set === "light" || set === "dark") return set;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(currentTheme());
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next: Theme = currentTheme() === "dark" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
// Until mounted the rendered label would disagree with the server output, so
|
||||
// the button carries a neutral label on first paint.
|
||||
const label = theme === null ? "Theme" : theme === "dark" ? "Light" : "Dark";
|
||||
|
||||
return (
|
||||
<button type="button" className="icon-btn" onClick={toggle} aria-label={`Switch to ${label.toLowerCase()} theme`}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const NAV_LINKS = [
|
||||
{ href: "/platform", label: "Platform" },
|
||||
{ href: "/pricing", label: "Pricing" },
|
||||
{ href: "/about", label: "About" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* The marketing site is a static bundle. Both forms post to sitesvc, which owns
|
||||
* the contact mailer and the signup flow; the control plane is not involved.
|
||||
*
|
||||
* The base URL is baked in at build time. Contact falls back to composing an
|
||||
* email when sitesvc is not configured, so it never silently swallows what
|
||||
* someone typed. Signup has no fallback: an account cannot be created over
|
||||
* mailto, so the form says so rather than pretending.
|
||||
*/
|
||||
|
||||
const SITE_API = (process.env.NEXT_PUBLIC_SITE_API ?? "").replace(/\/$/, "");
|
||||
const FALLBACK_ADDRESS = process.env.NEXT_PUBLIC_CONTACT_EMAIL ?? "support@hostxtra.co.uk";
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
if (res.ok) return { state: "sent" };
|
||||
|
||||
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." };
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Standalone output, matching web/: the build emits a self-contained server
|
||||
// bundle that runs under Node in the runtime image.
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "vantage-site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.5.3",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "16.2.9"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="533.000000pt" height="610.000000pt" viewBox="246.000000 207.000000 533.000000 610.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.16, written by Peter Selinger 2001-2019
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
|
||||
fill="#0B2A58" stroke="none">
|
||||
<path d="M4940 7767 c-96 -57 -528 -312 -960 -567 -678 -400 -1064 -628 -1187
|
||||
-702 l-33 -20 0 -1357 0 -1357 293 -174 c160 -96 425 -252 587 -348 946 -559
|
||||
1352 -799 1407 -833 34 -22 67 -39 72 -39 5 0 188 106 408 236 219 130 459
|
||||
272 533 315 74 44 425 252 780 462 l645 382 0 1355 0 1355 -135 81 c-140 84
|
||||
-1118 662 -1812 1070 -218 129 -402 236 -410 239 -7 3 -92 -42 -188 -98z m297
|
||||
-331 c309 -182 971 -572 1208 -712 149 -88 372 -220 498 -294 l227 -135 0
|
||||
-1175 0 -1175 -578 -342 c-317 -188 -694 -411 -837 -495 -143 -85 -344 -204
|
||||
-447 -265 l-186 -111 -314 185 c-987 584 -1710 1013 -1725 1025 -10 8 -13 256
|
||||
-13 1178 0 1099 1 1168 18 1182 9 8 150 93 312 188 162 96 417 246 565 333
|
||||
805 476 1149 677 1156 677 4 0 56 -29 116 -64z M4520 6930 c-157 -93 -432
|
||||
-256 -612 -362 -180 -105 -325 -195 -322 -199 2 -4 29 -21 59 -38 l55 -31 177
|
||||
0 178 0 235 140 c129 77 299 177 377 222 l143 83 0 178 c0 97 -1 177 -2 177
|
||||
-2 -1 -131 -77 -288 -170z M5430 6927 c0 -128 3 -177 13 -185 6 -5 77 -48 157
|
||||
-95 80 -46 245 -143 366 -216 l221 -131 177 0 176 0 55 31 c30 17 57 35 60 39
|
||||
3 5 -32 30 -77 56 -79 45 -673 396 -988 583 -80 47 -148 88 -152 89 -5 2 -8
|
||||
-75 -8 -171z M5050 6562 c-424 -251 -560 -334 -560 -342 0 -5 33 -79 73 -165
|
||||
52 -112 76 -154 87 -152 8 2 118 65 243 140 l228 135 77 -45 c251 -150 397
|
||||
-233 404 -231 7 3 122 238 148 304 8 20 -15 36 -303 205 -171 101 -316 185
|
||||
-321 187 -6 1 -40 -15 -76 -36z M3760 6109 c0 -6 187 -396 417 -867 229 -471
|
||||
483 -994 564 -1162 l148 -305 232 0 233 0 271 560 c150 308 403 830 564 1160
|
||||
160 329 291 605 291 612 0 19 -553 19 -568 1 -9 -12 -229 -480 -652 -1390 -73
|
||||
-158 -136 -285 -140 -283 -3 2 -100 206 -215 452 -115 246 -291 624 -390 838
|
||||
l-182 390 -287 3 c-202 2 -286 -1 -286 -9z M3270 5136 c0 -382 -3 -701 -6
|
||||
-710 -4 -9 -1 -16 6 -16 9 0 136 72 278 157 l22 13 0 539 0 539 -142 82 c-79
|
||||
46 -146 85 -150 87 -5 2 -8 -308 -8 -691z M6880 5779 c-47 -28 -113 -66 -147
|
||||
-86 l-63 -35 0 -537 0 -538 142 -84 c78 -46 147 -85 153 -87 7 -2 10 221 10
|
||||
708 0 390 -2 710 -5 710 -3 0 -43 -23 -90 -51z M3851 4935 l-1 -550 183 -107
|
||||
c100 -60 268 -159 372 -222 105 -63 191 -113 193 -111 4 3 -274 573 -288 590
|
||||
-6 8 -32 26 -56 40 l-44 26 0 76 0 76 -112 231 c-62 126 -143 291 -180 365
|
||||
l-67 136 0 -550z M6380 5471 c0 -5 -70 -152 -156 -327 -203 -414 -194 -393
|
||||
-194 -473 l0 -68 -51 -34 c-50 -34 -52 -38 -190 -323 -77 -159 -138 -290 -136
|
||||
-292 2 -3 127 69 278 158 151 90 316 188 367 218 l92 54 0 548 c0 301 -2 548
|
||||
-5 548 -3 0 -5 -4 -5 -9z M3722 3952 l-144 -86 49 -28 c26 -16 111 -66 188
|
||||
-112 136 -80 526 -311 830 -492 83 -49 153 -91 158 -92 4 -2 6 76 5 174 l-3
|
||||
178 -70 41 c-38 23 -246 146 -461 273 -216 128 -396 232 -400 231 -5 -1 -73
|
||||
-40 -152 -87z M6310 4011 c-25 -16 -124 -74 -220 -131 -96 -57 -284 -168 -417
|
||||
-247 l-243 -145 0 -174 c0 -96 2 -174 5 -174 2 0 37 20 77 44 40 24 195 116
|
||||
343 204 149 87 369 217 490 289 121 72 241 142 268 157 26 16 47 29 47 31 0 6
|
||||
-292 176 -301 174 -2 0 -24 -13 -49 -28z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||