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:
@@ -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",
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
70
apps/web/src/lib/coolify.ts
Normal file
70
apps/web/src/lib/coolify.ts
Normal 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
37
apps/web/src/lib/minio.ts
Normal 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
36
docs/phase5-deferred.md
Normal 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
3
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user