diff --git a/adminsite/app/(customer)/instances/new/CreateForm.tsx b/adminsite/app/(customer)/instances/new/CreateForm.tsx new file mode 100644 index 0000000..5e8afb6 --- /dev/null +++ b/adminsite/app/(customer)/instances/new/CreateForm.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ApiError, api } from "@/lib/api"; +import { Button } from "@/components/Button"; +import { Field } from "@/components/Field"; + +function slugify(value: string) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function CreateForm() { + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const router = useRouter(); + const qc = useQueryClient(); + + const create = useMutation({ + mutationFn: () => api.createInstance(name.trim()), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: ["account"] }); + router.push("/"); + }, + onError: (e) => + setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."), + }); + + const slug = slugify(name); + + return ( +
{ + e.preventDefault(); + setError(null); + if (name.trim()) create.mutate(); + }} + > + setName(e.target.value)} + placeholder="Northgate Systems" + required + error={error ?? undefined} + hint={`${slug || "your-instance"}.vantage.hostxtra.co.uk`} + /> + +

+ You sign in to it with this same email address and password. Changing one does not + change the other afterwards. +

+ + + + ); +} diff --git a/adminsite/app/(customer)/instances/new/page.tsx b/adminsite/app/(customer)/instances/new/page.tsx new file mode 100644 index 0000000..32da202 --- /dev/null +++ b/adminsite/app/(customer)/instances/new/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import { CreateForm } from "./CreateForm"; + +export const metadata: Metadata = { title: "New instance" }; + +export default function NewInstancePage() { + return ( +
+
+

Create a free instance

+

+ An instance owns its servers, keys, workflows, monitors and secrets. Nothing + inside it is visible to any other instance. Free covers three servers, and the + licence runs for a month at a time — we email you before it needs renewing. +

+
+ +
+ ); +} diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx index aa7eac9..6fcc4b5 100644 --- a/adminsite/app/(customer)/page.tsx +++ b/adminsite/app/(customer)/page.tsx @@ -58,10 +58,16 @@ export default function OverviewPage() {

No instances yet

- There are two ways to run Vantage. Buy a cloud instance and we host it, and - your licence is applied automatically. Or buy a self-hosted licence, install - Vantage on your own server, and link it here to get your licence file. + Create a free cloud instance and we host it, with your licence applied + automatically. Or buy a self-hosted licence, install Vantage on your own + server, and link it here to get your licence file.

+ + Create a free instance +
) : (
@@ -74,6 +80,18 @@ export default function OverviewPage() { ))}
)} + + {data.instances.length > 0 && + !data.instances.some( + (i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted", + ) && ( + + Create a free instance + + )} ); } diff --git a/adminsite/components/InstanceCard.tsx b/adminsite/components/InstanceCard.tsx index 44221b4..8bbb984 100644 --- a/adminsite/components/InstanceCard.tsx +++ b/adminsite/components/InstanceCard.tsx @@ -1,6 +1,9 @@ +"use client"; + import Link from "next/link"; import clsx from "clsx"; -import type { Instance, License } from "@/lib/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, type Instance, type License } from "@/lib/api"; import { daysRemaining, formatDate, licenceState } from "@/lib/format"; import { StatePill } from "./StatePill"; @@ -11,10 +14,28 @@ const STRIPE = { none: "before:bg-accent", } as const; -export function InstanceCard({ instance, license }: { instance: Instance; license?: License }) { +export function InstanceCard({ + instance, + license, + reapAfterDays, +}: { + instance: Instance; + license?: License; + reapAfterDays?: number; +}) { const state = licenceState(license?.expires_at, Boolean(license)); const days = license ? daysRemaining(license.expires_at) : 0; const cloud = instance.deployment === "cloud"; + const termDays = instance.tier === "free" ? 30 : 365; + const deleteInDays = + license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null; + + const qc = useQueryClient(); + const renew = useMutation({ + mutationFn: () => api.renewInstance(instance.instance_id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }), + }); + const canRenew = instance.tier === "free" && license !== undefined && days <= 7; return (
)} + {state === "expired" && deleteInDays !== null && ( +

+ {deleteInDays <= 0 + ? "Scheduled for deletion." + : `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`} +

+ )} + {state === "none" && (

You have paid for this but it is not attached to an install yet, so no licence @@ -55,35 +84,48 @@ export function InstanceCard({ instance, license }: { instance: Instance; licens

Renews {formatDate(license.expires_at)}
)} - {state === "none" ? ( - - Link an install - - ) : cloud && instance.slug ? ( - - Open {instance.slug}.vantage.hostxtra.co.uk - - ) : ( - - {state === "expired" ? "Renew and download" : "Licence and download"} - - )} +
+ {state === "none" ? ( + + Link an install + + ) : cloud && instance.slug ? ( + + Open {instance.slug}.vantage.hostxtra.co.uk + + ) : ( + + {state === "expired" ? "Renew and download" : "Licence and download"} + + )} + + {canRenew && ( + + )} +
); } diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts index 724f1ee..6698909 100644 --- a/adminsite/lib/api.ts +++ b/adminsite/lib/api.ts @@ -57,7 +57,7 @@ const post = (path: string, payload?: unknown) => export type Deployment = "cloud" | "self_hosted"; export type Tier = "free" | "professional" | "self_hosted"; -export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled"; +export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted"; export interface Session { kind: "staff" | "customer"; @@ -91,6 +91,7 @@ export interface Instance { current_license?: string; relink_count: number; inject_failed_at?: string | null; + notices_sent?: string[]; created_at: string; } @@ -187,6 +188,8 @@ export const api = { account: () => req("/api/account"), link: (instance_id: string, name: string) => post("/api/instances/link", { instance_id, name }), + createInstance: (name: string) => post("/api/instances", { name }), + renewInstance: (id: string) => post(`/api/instances/${id}/renew`, {}), relink: (id: string, instance_id: string) => post(`/api/instances/${id}/relink`, { instance_id }), license: (id: string) => req(`/api/instances/${id}/license`),