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:
Semih
2026-05-13 11:22:59 +00:00
parent 909cacf7b3
commit 31040a76b4
8 changed files with 446 additions and 20 deletions

View File

@@ -28,6 +28,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ioredis": "^5.4.1",
"minio": "^8.0.6",
"lucide-react": "^1.14.0",
"next": "^16.0.0",
"next-themes": "^0.4.6",

View 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>
);
}

View 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");
}
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,70 @@
const BASE = process.env.COOLIFY_API_BASE ?? "https://cool.semih.ai/api/v1";
const TOKEN = process.env.COOLIFY_API_TOKEN;
export class CoolifyError extends Error {
constructor(public status: number, public path: string, message: string) {
super(message);
}
}
async function request<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
if (!TOKEN) throw new CoolifyError(0, path, "COOLIFY_API_TOKEN not set");
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
cache: "no-store",
});
const text = await res.text();
if (!res.ok) {
throw new CoolifyError(res.status, path, text.slice(0, 200));
}
return (text ? JSON.parse(text) : null) as T;
}
export type CoolifyApplication = {
uuid: string;
name: string;
description?: string;
fqdn?: string | null;
status?: string;
git_repository?: string;
git_branch?: string;
git_commit_sha?: string;
build_pack?: string;
updated_at?: string;
};
export type CoolifyDeployment = {
deployment_uuid: string;
resource_uuid: string;
message?: string;
status?: string;
commit?: string;
created_at?: string;
};
export const coolify = {
listApplications: () => request<CoolifyApplication[]>("GET", "/applications"),
getApplication: (uuid: string) => request<CoolifyApplication>("GET", `/applications/${uuid}`),
deploy: (uuid: string, force = false) =>
request<{ deployments: CoolifyDeployment[] }>("POST", `/deploy?uuid=${uuid}&force=${force}`),
restart: (uuid: string) => request<{ message?: string }>("POST", `/applications/${uuid}/restart`),
recentDeployments: (uuid: string) =>
request<{ deployments: CoolifyDeployment[] } | CoolifyDeployment[]>(
"GET",
`/deployments/applications/${uuid}`,
),
};
/**
* Apps managed in this panel (whitelist). Restricts the Operations UI to these
* — we don't want to expose every Coolify app in this instance to the panel.
*/
export const MANAGED_APP_UUIDS = [
"os0w8sks8soo8k04cs0gg80k", // panel-web
"jk8gsg04w0kwo4sc4s84k8cc", // panel-worker
] as const;

37
apps/web/src/lib/minio.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Client as MinioClient } from "minio";
const ENDPOINT = process.env.MINIO_ENDPOINT ?? "minio-global";
const PORT = Number(process.env.MINIO_PORT ?? 9000);
const USE_SSL = (process.env.MINIO_USE_SSL ?? "false") === "true";
const ACCESS = process.env.MINIO_ACCESS_KEY ?? "";
const SECRET = process.env.MINIO_SECRET_KEY ?? "";
let _client: MinioClient | null = null;
export function getMinio(): MinioClient | null {
if (!ACCESS || !SECRET) return null;
if (_client) return _client;
_client = new MinioClient({ endPoint: ENDPOINT, port: PORT, useSSL: USE_SSL, accessKey: ACCESS, secretKey: SECRET });
return _client;
}
export type StoredObject = { name: string; size: number; lastModified: Date };
export async function listBucket(bucket: string, prefix = ""): Promise<StoredObject[]> {
const c = getMinio();
if (!c) return [];
const exists = await c.bucketExists(bucket).catch(() => false);
if (!exists) return [];
const out: StoredObject[] = [];
await new Promise<void>((resolve, reject) => {
const stream = c.listObjects(bucket, prefix, true);
stream.on("data", (o) => {
if (o.name && typeof o.size === "number" && o.lastModified) {
out.push({ name: o.name, size: o.size, lastModified: o.lastModified });
}
});
stream.on("end", () => resolve());
stream.on("error", reject);
});
return out;
}

36
docs/phase5-deferred.md Normal file
View File

@@ -0,0 +1,36 @@
# Phase 5 deferred: migration runner + Stripe webhook
## Migration runner
**Why deferred:** PRD envisions a per-project "Migrate Now" button with log stream — driven by `/internal/admin/migrate` on each spoke. No spoke currently exposes this endpoint. Building the panel UI for a button that calls nothing is premature.
**Trigger to revisit:**
- The first spoke ships `/internal/admin/migrate` (and ideally `/internal/admin/migrate/status`).
- OR migrations on the spokes start being a bottleneck (manual `prisma migrate deploy` on each host gets annoying).
**When triggered, do:**
1. Add `migrate()` and `migrateStatus()` to `@/lib/admin-sdk/<spoke>.ts`.
2. Add `/projects/[key]/migrate` page with a Server Action that calls migrate, then long-polls status (or upgrades to SSE if log streaming is needed).
3. Audit row is automatic (AdminClient already wraps with `writeAudit`).
4. Guard: require a manual confirm dialog (typed project key) — destructive on prod.
## Stripe central webhook receiver
**Why deferred:** no spoke currently uses Stripe (sase.tr's payments table exists but isn't wired to Stripe yet — at least the panel can't see Stripe traffic). A central receiver with no events to receive is dead code.
**Trigger to revisit:**
- A spoke goes live with Stripe and needs cross-project subscription/MRR rollups.
- OR a Stripe Connect / multi-account setup arrives and routing per-spoke is needed.
**When triggered, do:**
1. Add `STRIPE_WEBHOOK_SECRET` per spoke + `STRIPE_CENTRAL_SIGNING_SECRET` for panel.
2. `/api/webhooks/stripe` Route Handler: verify `Stripe-Signature` header (raw body required), normalize event, write to `Event` model with `projectKey` derived from account ID.
3. Re-publish into `<key>:events` Redis Stream so worker-side consumers see Stripe events alongside spoke-native events (single fan-out path).
4. Dashboard cards on `/` start showing MRR / new subs per project.
## What Phase 5 actually delivered
- `lib/coolify.ts` typed client (listApplications, deploy, restart, recentDeployments) with `COOLIFY_API_TOKEN` env.
- `/operations` page lists panel-managed apps (whitelist via `MANAGED_APP_UUIDS`), shows status / commit / branch / last deploy, with Deploy + Restart Server Actions (audited).
- `/operations` backup status section reads MinIO `panel-backups/` + `panel-audit-archive/` listings — latest dump age, total size, latest audit archive.
- Server Actions audited via the existing `writeAudit` helper.

3
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.6)
minio:
specifier: ^8.0.6
version: 8.0.7
next:
specifier: ^16.0.0
version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)