feat(phase5): coolify ops + backup status dashboard
- lib/coolify.ts typed client (whitelisted apps) - /operations: deploy/restart server actions per app, audited - /operations: backup status cards reading MinIO listings - lib/minio.ts shared client - docs/phase5-deferred.md (migration runner + stripe webhook rationale)
This commit is contained in:
64
apps/web/src/app/operations/_app-row.tsx
Normal file
64
apps/web/src/app/operations/_app-row.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { triggerDeploy, triggerRestart } from "./actions";
|
||||
|
||||
type Row = {
|
||||
uuid: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
commit?: string;
|
||||
branch?: string;
|
||||
lastDeployAt?: string | null;
|
||||
lastDeployStatus?: string | null;
|
||||
};
|
||||
|
||||
export function AppRow({ row }: { row: Row }) {
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const run = (fn: () => Promise<{ ok: boolean; error?: string; deploymentUuid?: string | null }>) =>
|
||||
start(async () => {
|
||||
const res = await fn();
|
||||
setFlash(res.ok ? (res.deploymentUuid ? `queued: ${res.deploymentUuid.slice(0, 8)}` : "ok") : `err: ${res.error}`);
|
||||
setTimeout(() => setFlash(null), 5000);
|
||||
});
|
||||
|
||||
const running = row.status?.startsWith("running");
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{row.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={running ? "default" : "outline"}>{row.status ?? "unknown"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.commit?.slice(0, 8) ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.branch ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.lastDeployAt ? `${row.lastDeployStatus} · ${row.lastDeployAt}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => run(() => triggerDeploy(row.uuid, false))}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => run(() => triggerRestart(row.uuid))}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/app/operations/actions.ts
Normal file
72
apps/web/src/app/operations/actions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { coolify, MANAGED_APP_UUIDS, CoolifyError } from "@/lib/coolify";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
type AppId = (typeof MANAGED_APP_UUIDS)[number];
|
||||
|
||||
function assertManaged(uuid: string): asserts uuid is AppId {
|
||||
if (!(MANAGED_APP_UUIDS as readonly string[]).includes(uuid)) {
|
||||
throw new Error("application not managed by panel");
|
||||
}
|
||||
}
|
||||
|
||||
async function gate() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("unauthorized");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function triggerDeploy(uuid: string, force = false) {
|
||||
await gate();
|
||||
assertManaged(uuid);
|
||||
const started = Date.now();
|
||||
let status = 0;
|
||||
let error: string | null = null;
|
||||
try {
|
||||
const res = await coolify.deploy(uuid, force);
|
||||
status = 200;
|
||||
return { ok: true as const, deploymentUuid: res.deployments?.[0]?.deployment_uuid ?? null };
|
||||
} catch (e) {
|
||||
status = e instanceof CoolifyError ? e.status : 500;
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false as const, error };
|
||||
} finally {
|
||||
void writeAudit({
|
||||
projectKey: "panel",
|
||||
endpoint: `coolify:deploy:${uuid}`,
|
||||
method: "POST",
|
||||
requestPayload: { force },
|
||||
responseStatus: status,
|
||||
durationMs: Date.now() - started,
|
||||
}).catch(() => {});
|
||||
revalidatePath("/operations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerRestart(uuid: string) {
|
||||
await gate();
|
||||
assertManaged(uuid);
|
||||
const started = Date.now();
|
||||
let status = 0;
|
||||
try {
|
||||
await coolify.restart(uuid);
|
||||
status = 200;
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
status = e instanceof CoolifyError ? e.status : 500;
|
||||
return { ok: false as const, error: e instanceof Error ? e.message : String(e) };
|
||||
} finally {
|
||||
void writeAudit({
|
||||
projectKey: "panel",
|
||||
endpoint: `coolify:restart:${uuid}`,
|
||||
method: "POST",
|
||||
responseStatus: status,
|
||||
durationMs: Date.now() - started,
|
||||
}).catch(() => {});
|
||||
revalidatePath("/operations");
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,170 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { coolify, MANAGED_APP_UUIDS } from "@/lib/coolify";
|
||||
import { listBucket } from "@/lib/minio";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AppRow } from "./_app-row";
|
||||
|
||||
function humanBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let v = n / 1024;
|
||||
for (const u of units) {
|
||||
if (v < 1024) return `${v.toFixed(1)} ${u}`;
|
||||
v /= 1024;
|
||||
}
|
||||
return `${v.toFixed(1)} PB`;
|
||||
}
|
||||
|
||||
function ageOf(d: Date) {
|
||||
const ms = Date.now() - d.getTime();
|
||||
const h = Math.floor(ms / 3_600_000);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
async function loadBackupStatus() {
|
||||
const [dumps, archives] = await Promise.all([
|
||||
listBucket(process.env.MINIO_BACKUP_BUCKET ?? "panel-backups", "panel-postgres/").catch(() => []),
|
||||
listBucket(process.env.MINIO_AUDIT_BUCKET ?? "panel-audit-archive", "audit/").catch(() => []),
|
||||
]);
|
||||
const sortDesc = <T extends { lastModified: Date }>(rows: T[]) =>
|
||||
[...rows].sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
||||
return { dumps: sortDesc(dumps), archives: sortDesc(archives) };
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function loadApps() {
|
||||
return Promise.all(
|
||||
MANAGED_APP_UUIDS.map(async (uuid) => {
|
||||
try {
|
||||
const [app, depsRaw] = await Promise.all([
|
||||
coolify.getApplication(uuid),
|
||||
coolify.recentDeployments(uuid).catch(() => [] as never),
|
||||
]);
|
||||
const deps = Array.isArray(depsRaw)
|
||||
? depsRaw
|
||||
: (depsRaw as { deployments?: unknown[] }).deployments ?? [];
|
||||
const latest = (deps as Array<{ status?: string; created_at?: string }>)[0];
|
||||
return {
|
||||
uuid,
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
commit: app.git_commit_sha,
|
||||
branch: app.git_branch,
|
||||
lastDeployAt: latest?.created_at ?? null,
|
||||
lastDeployStatus: latest?.status ?? null,
|
||||
error: null as string | null,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
uuid,
|
||||
name: uuid,
|
||||
status: undefined,
|
||||
commit: undefined,
|
||||
branch: undefined,
|
||||
lastDeployAt: null,
|
||||
lastDeployStatus: null,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export default async function OperationsPage() {
|
||||
const [rows, backups] = await Promise.all([loadApps(), loadBackupStatus()]);
|
||||
const latestDump = backups.dumps[0];
|
||||
const latestArchive = backups.archives[0];
|
||||
const totalDumpBytes = backups.dumps.reduce((s, d) => s + d.size, 0);
|
||||
|
||||
export default function OperationsPage() {
|
||||
return (
|
||||
<PanelShell title="Operations">
|
||||
<ComingSoon
|
||||
title="Operations"
|
||||
items={["Coolify deploy triggers", "Backup status", "Migration runner"]}
|
||||
phase="Phase 5"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Coolify-managed apps for this panel. Deploys and restarts are audited.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>App</TableHead>
|
||||
<TableHead className="w-[140px]">Status</TableHead>
|
||||
<TableHead className="w-[100px]">Commit</TableHead>
|
||||
<TableHead className="w-[120px]">Branch</TableHead>
|
||||
<TableHead>Last deploy</TableHead>
|
||||
<TableHead className="w-[280px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<AppRow key={r.uuid} row={r} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-4 text-sm font-medium text-muted-foreground">Backup status</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Latest panel-postgres dump</CardDescription>
|
||||
<CardTitle className="text-base">
|
||||
{latestDump ? (
|
||||
<span className="font-mono text-sm">{ageOf(latestDump.lastModified)}</span>
|
||||
) : (
|
||||
<Badge variant="outline">none</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{latestDump && (
|
||||
<CardContent className="space-y-1 font-mono text-xs text-muted-foreground">
|
||||
<div>{latestDump.name}</div>
|
||||
<div>{humanBytes(latestDump.size)} · {latestDump.lastModified.toISOString().slice(0, 19).replace("T", " ")}</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>All dumps</CardDescription>
|
||||
<CardTitle className="text-base">
|
||||
{backups.dumps.length} files · {humanBytes(totalDumpBytes)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
Daily 04:00 UTC. No lifecycle yet — grows unbounded.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Latest audit archive</CardDescription>
|
||||
<CardTitle className="text-base">
|
||||
{latestArchive ? (
|
||||
<span className="font-mono text-sm">{ageOf(latestArchive.lastModified)}</span>
|
||||
) : (
|
||||
<Badge variant="outline">none</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{latestArchive && (
|
||||
<CardContent className="space-y-1 font-mono text-xs text-muted-foreground">
|
||||
<div>{latestArchive.name}</div>
|
||||
<div>{humanBytes(latestArchive.size)}</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ComingSoon({ title, items, phase }: { title: string; items: string[]; phase: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-8">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Planned for {phase}. Will include:</p>
|
||||
<ul className="mt-3 list-inside list-disc text-sm text-muted-foreground">
|
||||
{items.map((i) => (
|
||||
<li key={i}>{i}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user