initial NextChair Manager.
CI / ci (push) Canceled after 0s

Key additions:
* Separate platform Manager stack: [stacks/manager/compose.yaml]
* Dedicated manager authentication with signed sessions.
* Business overview dashboard.
* Add Business workflow with slug suggestions and deployment progress.
* Business detail page with start, stop, restart, update, logs, Dockge, and conservative removal actions.
* Explicit management API endpoints under [src/app/api/manager]
* Shared provisioning service layer in [provisioning.service.ts]
* CLI and UI now use the same [create-business.sh] provisioning logic.
* Registry metadata, database status, container health, and media usage reporting.
* Manager-only entrypoint mode that does not require a business database.
* Updated deployment documentation with Manager setup and Caddy routing.
Validation passed:
* Infra, Caddy, Manager, and generated business Compose configurations.
* Shell syntax checks.
* Registry generation with registry image tags.
* git diff --check.
This commit is contained in:
Stefan Lazic committed 2026-09-04 20:54:58 +01:00
1 parent 764d087eac
commit b219fb4519
24 files changed
+596 -11

No files matched your search

@@ -0,0 +1,41 @@
"use client";
import { ArrowLeft, Check, Globe, Rocket } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
const steps = ["Creating database", "Generating configuration", "Creating Dockge stack", "Starting application", "Configuring Caddy", "Checking application"];
export function ManagerAddBusiness() {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [domain, setDomain] = useState("");
const [image, setImage] = useState("nextchair:latest");
const [pending, setPending] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [deployed, setDeployed] = useState<string | null>(null);
function changeName(value: string) {
setName(value);
if (!slugTouched) setSlug(value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 22));
}
async function deploy(event: React.FormEvent) {
event.preventDefault();
setPending(true); setError(null); setProgress(1);
const timer = window.setInterval(() => setProgress((value) => Math.min(value + 1, steps.length)), 900);
const response = await fetch("/api/manager/businesses", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, slug, domain, image }) });
window.clearInterval(timer);
if (!response.ok) { setError((await response.json()).error ?? "Deployment failed."); setPending(false); return; }
setProgress(steps.length); setPending(false); setDeployed(slug);
}
if (deployed) return <main className="mx-auto max-w-2xl px-5 py-12 sm:px-8"><Card className="p-8 text-center sm:p-12"><div className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-accent/10 text-accent-foreground"><Check /></div><h1 className="text-3xl font-bold">Business deployed successfully</h1><p className="mt-3 text-sm text-muted">The application stack is ready. Complete the business setup before inviting customers.</p><div className="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center"><Button asChild><a href={`https://${domain}`} target="_blank" rel="noreferrer">Open Business</a></Button><Button asChild variant="secondary"><a href={`https://${domain}/install`} target="_blank" rel="noreferrer">Continue Setup</a></Button></div></Card></main>;
return <main className="mx-auto max-w-3xl px-5 py-8 sm:px-8"><Link href="/manager" className="mb-10 inline-flex items-center gap-2 text-xs font-bold tracking-widest text-muted uppercase"><ArrowLeft className="h-4 w-4" /> Businesses</Link><div className="mb-10"><p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">New deployment</p><h1 className="text-4xl font-bold tracking-tight">Add Business</h1><p className="mt-2 text-sm text-muted">Create an isolated NextChair application with its own data and media storage.</p></div><Card className="p-6 sm:p-8"><form onSubmit={deploy} className="space-y-5"><Input label="Business name" required value={name} onChange={(event) => changeName(event.target.value)} placeholder="Blake Heritage" /><Input label="Slug" required value={slug} onChange={(event) => { setSlugTouched(true); setSlug(event.target.value.toLowerCase()); }} hint="Lowercase letters, numbers, and hyphens. Maximum 22 characters." placeholder="blake-heritage" /><Input label="Domain" required value={domain} onChange={(event) => setDomain(event.target.value.toLowerCase())} icon={<Globe className="h-4 w-4" />} placeholder="blakeheritage.com" /><Input label="Application version" required value={image} onChange={(event) => setImage(event.target.value)} hint="Use nextchair:latest locally or a pinned registry image in production." placeholder="nextchair:2026-09-04" />{error && <p className="text-sm font-medium text-danger" role="alert">{error}</p>}{pending && <div className="rounded-xl bg-stone p-4"><p className="mb-3 text-xs font-bold tracking-widest uppercase">Deployment progress</p><div className="space-y-2">{steps.map((step, index) => <p key={step} className={`flex items-center gap-2 text-sm ${index < progress ? "text-accent-foreground" : "text-muted"}`}>{index < progress ? <Check className="h-4 w-4" /> : <span className="h-4 w-4 rounded-full border border-line" />}{step}{index === progress - 1 && <span className="ml-auto text-xs">Working</span>}</p>)}</div></div>}<Button type="submit" className="w-full" isLoading={pending}><Rocket className="h-4 w-4" /> Deploy Business</Button></form></Card></main>;
}
@@ -0,0 +1,34 @@
"use client";
import { ArrowLeft, ExternalLink, Play, RotateCw, Square, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { BusinessStatus } from "@/services/management/types";
function formatBytes(value: number | null) { return value === null ? "Not available" : value < 1024 * 1024 ? `${Math.round(value / 1024)} KB` : `${(value / 1024 / 1024).toFixed(1)} MB`; }
export function ManagerBusinessDetail({ business: initialBusiness, dockgeUrl }: { business: BusinessStatus; dockgeUrl: string }) {
const [business, setBusiness] = useState(initialBusiness);
const [pending, setPending] = useState<string | null>(null);
const [logs, setLogs] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function action(actionName: string) {
if (actionName === "remove" && !window.confirm("Remove the application deployment? The database and media volume will be kept.")) return;
setPending(actionName); setError(null);
const response = await fetch(`/api/manager/businesses/${business.slug}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: actionName }) });
const result = await response.json();
setPending(null);
if (!response.ok) { setError(result.error ?? "Action failed."); return; }
if (actionName === "remove") { window.location.assign("/manager"); return; }
setBusiness(result);
}
async function showLogs() {
setPending("logs"); const response = await fetch(`/api/manager/businesses/${business.slug}/logs`); setLogs(await response.text()); setPending(null);
}
return <main className="mx-auto max-w-5xl px-5 py-8 sm:px-8"><Link href="/manager" className="mb-10 inline-flex items-center gap-2 text-xs font-bold tracking-widest text-muted uppercase"><ArrowLeft className="h-4 w-4" /> Businesses</Link><header className="mb-8 flex flex-col justify-between gap-5 sm:flex-row sm:items-end"><div><p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">Business deployment</p><h1 className="text-4xl font-bold tracking-tight">{business.name}</h1><p className="mt-2 text-sm text-muted">{business.domain} <span className="px-1">·</span> {business.slug}</p></div><div className="flex flex-wrap gap-2"><Button asChild size="sm"><a href={`https://${business.domain}`} target="_blank" rel="noreferrer">Open Business <ExternalLink className="h-3.5 w-3.5" /></a></Button><Button asChild size="sm" variant="secondary"><a href={`https://${business.domain}/owner`} target="_blank" rel="noreferrer">Open Admin <ExternalLink className="h-3.5 w-3.5" /></a></Button></div></header><div className="grid gap-4 md:grid-cols-2"><Card className="p-6"><h2 className="mb-5 text-lg font-bold">Runtime</h2><dl className="grid grid-cols-2 gap-5 text-sm"><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Application version</dt><dd className="mt-1 break-all font-medium">{business.image}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Container</dt><dd className="mt-1 font-medium">{business.container}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Health</dt><dd className="mt-1 font-medium">{business.health}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Database</dt><dd className="mt-1 font-medium">{business.database}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Media storage</dt><dd className="mt-1 font-medium">{formatBytes(business.storageBytes)}</dd></div><div><dt className="text-[10px] font-bold tracking-widest text-muted uppercase">Last updated</dt><dd className="mt-1 font-medium">{new Date(business.updatedAt).toLocaleString()}</dd></div></dl></Card><Card className="p-6"><h2 className="mb-5 text-lg font-bold">Manage deployment</h2><div className="flex flex-wrap gap-2"><Button size="sm" onClick={() => action("start")} isLoading={pending === "start"}><Play className="h-3.5 w-3.5" /> Start</Button><Button size="sm" variant="secondary" onClick={() => action("stop")} isLoading={pending === "stop"}><Square className="h-3.5 w-3.5" /> Stop</Button><Button size="sm" variant="secondary" onClick={() => action("restart")} isLoading={pending === "restart"}><RotateCw className="h-3.5 w-3.5" /> Restart</Button><Button size="sm" variant="secondary" onClick={() => action("update")} isLoading={pending === "update"}>Update</Button><Button size="sm" variant="ghost" onClick={showLogs} isLoading={pending === "logs"}>View Logs</Button>{dockgeUrl && <Button asChild size="sm" variant="ghost"><a href={`${dockgeUrl.replace(/\/$/, "")}/compose/${business.slug}`} target="_blank" rel="noreferrer">Open in Dockge</a></Button>}</div><p className="mt-6 text-xs leading-relaxed text-muted">Update is explicit and uses the image pinned to this business. Removing the application does not delete its database or media volume.</p><Button size="sm" variant="destructive" className="mt-5" onClick={() => action("remove")} isLoading={pending === "remove"}><Trash2 className="h-3.5 w-3.5" /> Remove application</Button></Card></div>{error && <p className="mt-5 text-sm font-medium text-danger" role="alert">{error}</p>}{logs !== null && <Card className="mt-4 overflow-hidden"><div className="border-b border-line px-6 py-4"><h2 className="font-bold">Recent logs</h2></div><pre className="max-h-[32rem] overflow-auto bg-ink p-6 text-xs leading-relaxed whitespace-pre-wrap text-white">{logs}</pre></Card>}</main>;
}
@@ -0,0 +1,60 @@
"use client";
import { ExternalLink, LogOut, Plus, RefreshCw, Server, Store, Activity } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { BusinessStatus } from "@/services/management/types";
function formatBytes(value: number | null) {
if (value === null) return "Not available";
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function stateTone(value: string) {
return value === "running" || value === "healthy" || value === "online" ? "text-accent-foreground" : value === "unknown" ? "text-muted" : "text-danger";
}
export function ManagerDashboard({ initialBusinesses }: { initialBusinesses: BusinessStatus[] }) {
const [businesses, setBusinesses] = useState(initialBusinesses);
const [loading, setLoading] = useState(false);
async function refresh() {
setLoading(true);
const response = await fetch("/api/manager/businesses", { cache: "no-store" });
if (response.ok) setBusinesses(await response.json());
setLoading(false);
}
async function signOut() {
await fetch("/api/manager/auth", { method: "DELETE" });
window.location.assign("/manager/sign-in");
}
return (
<main className="mx-auto max-w-7xl px-5 py-8 sm:px-8 lg:px-10">
<header className="mb-12 flex items-start justify-between gap-4">
<div>
<p className="mb-3 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">NextChair Manager</p>
<h1 className="text-4xl font-bold tracking-tight">Businesses</h1>
<p className="mt-2 text-sm text-muted">Product-level deployment control. Detailed Docker operations remain in Dockge.</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" onClick={refresh} aria-label="Refresh businesses"><RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /></Button>
<Button variant="ghost" size="icon" onClick={signOut} aria-label="Sign out"><LogOut className="h-4 w-4" /></Button>
</div>
</header>
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card className="p-5"><Store className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.length}</p><p className="mt-1 text-xs text-muted">Deployed businesses</p></Card>
<Card className="p-5"><Activity className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.filter((business) => business.container === "running").length}</p><p className="mt-1 text-xs text-muted">Running applications</p></Card>
<Card className="p-5"><Server className="mb-6 h-5 w-5 text-accent-foreground" /><p className="text-3xl font-bold">{businesses.filter((business) => business.database === "online").length}</p><p className="mt-1 text-xs text-muted">Database connections</p></Card>
</div>
<div className="mb-5 flex items-center justify-between"><h2 className="text-xl font-bold">All deployments</h2><Button asChild size="sm"><Link href="/manager/add"><Plus className="h-4 w-4" /> Add Business</Link></Button></div>
{businesses.length === 0 ? <Card className="p-12 text-center"><p className="font-bold">No businesses deployed yet.</p><p className="mt-2 text-sm text-muted">Create the first business to start its isolated application stack.</p></Card> : <div className="grid gap-4 lg:grid-cols-2">{businesses.map((business) => <Card key={business.slug} className="p-6"><div className="flex items-start justify-between gap-4"><div><p className="text-xl font-bold">{business.name}</p><p className="mt-1 text-sm text-muted">{business.domain} <span className="px-1">·</span> {business.slug}</p></div><span className={`rounded-full bg-stone px-3 py-1 text-[10px] font-bold tracking-widest uppercase ${stateTone(business.container)}`}>{business.container}</span></div><div className="mt-6 grid grid-cols-2 gap-y-4 text-sm"><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Version</p><p className="mt-1 font-medium">{business.image}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Health</p><p className={`mt-1 font-medium ${stateTone(business.health)}`}>{business.health}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Database</p><p className={`mt-1 font-medium ${stateTone(business.database)}`}>{business.database}</p></div><div><p className="text-[10px] font-bold tracking-widest text-muted uppercase">Media</p><p className="mt-1 font-medium">{formatBytes(business.storageBytes)}</p></div></div><div className="mt-6 flex flex-wrap gap-2 border-t border-line pt-5"><Button asChild size="sm" variant="secondary"><Link href={`/manager/businesses/${business.slug}`}>Manage</Link></Button><Button asChild size="sm" variant="ghost"><a href={`https://${business.domain}`} target="_blank" rel="noreferrer">Open business <ExternalLink className="h-3.5 w-3.5" /></a></Button></div></Card>)}</div>}
</main>
);
}
@@ -0,0 +1,39 @@
"use client";
import { Lock, Mail } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function ManagerSignInForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setError(null);
const response = await fetch("/api/manager/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) });
setPending(false);
if (!response.ok) {
setError("Incorrect manager credentials.");
return;
}
router.push("/manager");
router.refresh();
}
return (
<form onSubmit={submit} className="space-y-4">
<Input label="Administrator email" type="email" required value={email} onChange={(event) => setEmail(event.target.value)} icon={<Mail className="h-4 w-4" />} />
<Input label="Password" type="password" required value={password} onChange={(event) => setPassword(event.target.value)} icon={<Lock className="h-4 w-4" />} />
{error && <p className="text-sm font-medium text-danger" role="alert">{error}</p>}
<Button type="submit" className="w-full" isLoading={pending}>Sign in to Manager</Button>
</form>
);
}