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.
35 lines
5.6 KiB
TypeScript
35 lines
5.6 KiB
TypeScript
"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>;
|
|
}
|