feat: customer detail page, size snapshots table, Spaces provisioning, Redis status cache
Some checks failed
Build & Release / build (push) Has been cancelled

This commit is contained in:
Ryan Moon
2026-04-03 20:07:18 -05:00
parent bc9d7b464c
commit b11b51aa1e
11 changed files with 832 additions and 273 deletions

View File

@@ -1,13 +1,16 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import crypto from "crypto";
import postgres from "postgres";
import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser } from "../services/do";
import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer";
import { addCustomerChart, removeCustomerChart } from "../services/git";
import { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db";
import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret } from "../lib/k8s";
import { createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret, patchSecret, getSecret, k8sFetch } from "../lib/k8s";
import { createSpacesKey, deleteSpacesKey, deleteSpacesObjects, getSpacesUsage } from "../services/spaces";
import { db } from "../db/manager";
import { config } from "../lib/config";
import { getCachedStatus, setCachedStatus } from "../lib/cache";
const MODULES = ["pos", "inventory", "rentals", "scheduling", "repairs", "accounting"] as const;
const PGBOUNCER_HOST = "pgbouncer.pgbouncer.svc";
@@ -71,6 +74,7 @@ export async function customerRoutes(app: FastifyInstance) {
pool: "pending",
namespace: "pending",
secrets: "pending",
storage: "pending",
chart: "pending",
};
@@ -122,6 +126,20 @@ export async function customerRoutes(app: FastifyInstance) {
await setStep("namespace", "done");
await setStep("secrets", "done");
const { accessKey: spacesKey, secretKey: spacesSecret } = await createSpacesKey(
`customer-${slug}`,
config.spacesBucket,
);
await patchSecret(namespace, "lunarfront-secrets", {
"spaces-key": spacesKey,
"spaces-secret": spacesSecret,
"spaces-bucket": config.spacesBucket,
"spaces-endpoint": `https://${config.spacesRegion}.digitaloceanspaces.com`,
"spaces-prefix": `${slug}/`,
});
await db`UPDATE customers SET spaces_key = ${spacesKey}, updated_at = NOW() WHERE slug = ${slug}`;
await setStep("storage", "done");
addCustomerChart(slug, body.appVersion);
await setStep("chart", "done");
@@ -144,9 +162,20 @@ export async function customerRoutes(app: FastifyInstance) {
app.log.info({ slug }, "deprovisioning customer");
const [customer] = await db`SELECT spaces_key FROM customers WHERE slug = ${slug}`;
removeCustomerChart(slug);
await removeCustomerFromPool(slug);
await teardownCustomerDatabase(slug, slug);
if (customer?.spaces_key) {
try {
const secrets = await getSecret(namespace, "lunarfront-secrets");
await deleteSpacesObjects(customer.spaces_key, secrets["spaces-secret"], config.spacesBucket, config.spacesRegion, `${slug}/`);
} catch {}
try { await deleteSpacesKey(customer.spaces_key); } catch {}
}
await Promise.all([
deleteDatabase(slug),
deleteDatabaseUser(slug),
@@ -159,6 +188,63 @@ export async function customerRoutes(app: FastifyInstance) {
return reply.code(204).send();
});
// Live overview: cached status (pods + ArgoCD) + latest size snapshot + 30d history
app.get("/customers/:slug/overview", async (req, reply) => {
const { slug } = req.params as { slug: string };
const namespace = `customer-${slug}`;
const { refresh } = req.query as { refresh?: string };
const [customer] = await db`SELECT * FROM customers WHERE slug = ${slug}`;
if (!customer) return reply.code(404).send({ message: "Not found" });
// ── Status (Redis cache, 2min TTL) ────────────────────────────────────────
let statusEntry = refresh ? null : await getCachedStatus(slug);
if (!statusEntry) {
const [podsResult, argoResult] = await Promise.allSettled([
k8sFetch(`/api/v1/namespaces/${namespace}/pods`).then(r => r.json()),
k8sFetch(`/apis/argoproj.io/v1alpha1/namespaces/argocd/applications/customer-${slug}`).then(r => r.json()),
]);
const podsRaw = podsResult.status === "fulfilled" ? podsResult.value : null;
const argoRaw = argoResult.status === "fulfilled" ? argoResult.value : null;
const pods = (podsRaw?.items ?? []).map((pod: any) => ({
name: pod.metadata.name,
ready: (pod.status.containerStatuses ?? []).every((c: any) => c.ready),
readyCount: (pod.status.containerStatuses ?? []).filter((c: any) => c.ready).length,
totalCount: (pod.status.containerStatuses ?? []).length,
status: pod.status.phase ?? "Unknown",
restarts: (pod.status.containerStatuses ?? []).reduce((s: number, c: any) => s + (c.restartCount ?? 0), 0),
startedAt: pod.status.startTime ?? null,
}));
const argocd = argoRaw?.status ? {
syncStatus: argoRaw.status.sync?.status ?? "Unknown",
healthStatus: argoRaw.status.health?.status ?? "Unknown",
conditions: (argoRaw.status.conditions ?? []).map((c: any) => ({ type: c.type, message: c.message })),
} : null;
const liveStatus = { pods, argocd };
await setCachedStatus(slug, liveStatus);
statusEntry = { data: liveStatus, cachedAt: new Date().toISOString() };
}
// ── Size history (last 30 days) ───────────────────────────────────────────
const sizeHistory = await db`
SELECT recorded_at, db_size_bytes, spaces_size_bytes, spaces_object_count
FROM customer_size_snapshots
WHERE slug = ${slug}
ORDER BY recorded_at DESC
LIMIT 30
`;
return reply.send({
customer,
status: { ...statusEntry.data, cachedAt: statusEntry.cachedAt },
sizeHistory,
});
});
// Remove only the manager DB record without touching infrastructure —
// useful for cleaning up failed partial deployments
app.delete("/customers/:slug/record", async (req, reply) => {