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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user