diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/changelog/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/changelog/page.tsx new file mode 100644 index 0000000..521e659 --- /dev/null +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/changelog/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ChangelogHistory } from "@/components/service/details/changelog-history"; +import { useService } from "@/components/service/service-layout-client"; + +export default function ChangelogPage() { + const { service, projectSlug, envName } = useService(); + + return ( + + ); +} diff --git a/web/app/(dashboard)/layout-client.tsx b/web/app/(dashboard)/layout-client.tsx index 1bbc205..9de03ef 100644 --- a/web/app/(dashboard)/layout-client.tsx +++ b/web/app/(dashboard)/layout-client.tsx @@ -1,17 +1,16 @@ "use client"; +import { LogOut, Settings, User } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { LogOut, Settings, User } from "lucide-react"; import { BreadcrumbDataProvider, useBreadcrumbs, } from "@/components/core/breadcrumb-data"; import { DashboardPageSkeleton } from "@/components/dashboard/dashboard-page-skeleton"; import { OfflineServersBanner } from "@/components/server/offline-servers-banner"; -import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -24,13 +23,7 @@ import { import { Toaster } from "@/components/ui/sonner"; import { signOut, useSession } from "@/lib/auth-client"; -function DashboardHeader({ - email, - name, -}: { - email: string; - name: string; -}) { +function DashboardHeader({ email, name }: { email: string; name: string }) { const router = useRouter(); const breadcrumbs = useBreadcrumbs(); const getBreadcrumbKey = ( @@ -43,8 +36,8 @@ function DashboardHeader({ const showEllipsis = breadcrumbs.length > 2; return ( -
-
+
+
- + @@ -183,10 +172,7 @@ export function DashboardLayoutClient({ return (
- +
{children}
diff --git a/web/app/api/services/[id]/revisions/route.ts b/web/app/api/services/[id]/revisions/route.ts new file mode 100644 index 0000000..4a2cfe9 --- /dev/null +++ b/web/app/api/services/[id]/revisions/route.ts @@ -0,0 +1,216 @@ +export const dynamic = "force-dynamic"; + +import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { rollouts, servers, serviceRevisions, services } from "@/db/schema"; +import { requireRequestSession } from "@/lib/api-auth"; +import { + diffServiceRevisionSpecs, + parseServiceRevisionSpec, + type ServiceRevisionChangelogItem, + type ServiceRevisionChangelogResponse, +} from "@/lib/service-revision-changes"; +import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; + +const PAGE_SIZE = 25; +const REVISION_CURSOR_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}(?::?\d{2})?)$/; + +type RevisionCursor = { + createdAt: string; + id: string; +}; + +function encodeCursor(cursor: RevisionCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +function decodeCursor(value: string): RevisionCursor | null { + try { + const decoded = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ) as unknown; + if (!decoded || typeof decoded !== "object") return null; + + const cursor = decoded as Partial; + if ( + typeof cursor.createdAt !== "string" || + !REVISION_CURSOR_TIMESTAMP.test(cursor.createdAt) || + Number.isNaN(Date.parse(cursor.createdAt)) || + typeof cursor.id !== "string" || + cursor.id.length === 0 || + cursor.id.length > 200 + ) { + return null; + } + + return { createdAt: cursor.createdAt, id: cursor.id }; + } catch { + return null; + } +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const sessionResult = await requireRequestSession(request); + if (!sessionResult.ok) return sessionResult.response; + + const { id: serviceId } = await params; + const cursorValue = new URL(request.url).searchParams.get("cursor"); + const cursor = cursorValue ? decodeCursor(cursorValue) : null; + if (cursorValue && !cursor) { + return Response.json( + { message: "Invalid revision cursor" }, + { status: 400 }, + ); + } + + const service = await db + .select({ id: services.id }) + .from(services) + .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) + .then((rows) => rows[0]); + if (!service) { + return Response.json({ message: "Service not found" }, { status: 404 }); + } + + const revisions = await db + .select({ + id: serviceRevisions.id, + createdAt: serviceRevisions.createdAt, + cursorCreatedAt: sql`${serviceRevisions.createdAt}::text`, + specification: serviceRevisions.specification, + }) + .from(serviceRevisions) + .where( + and( + eq(serviceRevisions.serviceId, serviceId), + cursor + ? or( + lt( + serviceRevisions.createdAt, + sql`${cursor.createdAt}::timestamptz`, + ), + and( + eq( + serviceRevisions.createdAt, + sql`${cursor.createdAt}::timestamptz`, + ), + lt(serviceRevisions.id, cursor.id), + ), + ) + : undefined, + ), + ) + .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id)) + .limit(PAGE_SIZE + 1); + + const pageRevisions = revisions.slice(0, PAGE_SIZE); + const revisionIds = pageRevisions.map((revision) => revision.id); + const parsedSpecifications = new Map(); + for (const revision of revisions) { + try { + parsedSpecifications.set( + revision.id, + parseServiceRevisionSpec(revision.specification), + ); + } catch { + // Unsupported or malformed historical revisions remain visible. + } + } + const placementServerIds = [ + ...new Set( + [...parsedSpecifications.values()].flatMap((specification) => + specification.placements.map((placement) => placement.serverId), + ), + ), + ]; + const [serverRows, revisionRollouts] = await Promise.all([ + placementServerIds.length > 0 + ? db + .select({ id: servers.id, name: servers.name }) + .from(servers) + .where(inArray(servers.id, placementServerIds)) + : Promise.resolve([]), + revisionIds.length > 0 + ? db + .select({ + id: rollouts.id, + serviceRevisionId: rollouts.serviceRevisionId, + status: rollouts.status, + createdAt: rollouts.createdAt, + }) + .from(rollouts) + .where( + and( + eq(rollouts.serviceId, serviceId), + inArray(rollouts.serviceRevisionId, revisionIds), + ), + ) + .orderBy(desc(rollouts.createdAt), desc(rollouts.id)) + : Promise.resolve([]), + ]); + const serverNames = new Map( + serverRows.map((server) => [server.id, server.name] as const), + ); + const rolloutByRevisionId = new Map< + string, + (typeof revisionRollouts)[number] + >(); + for (const rollout of revisionRollouts) { + if ( + rollout.serviceRevisionId && + !rolloutByRevisionId.has(rollout.serviceRevisionId) + ) { + rolloutByRevisionId.set(rollout.serviceRevisionId, rollout); + } + } + + const items: ServiceRevisionChangelogItem[] = pageRevisions.map( + (revision, index) => { + const previous = revisions[index + 1]; + let comparison: ServiceRevisionChangelogItem["comparison"]; + if (!previous) { + comparison = { kind: "initial" }; + } else { + const currentSpecification = parsedSpecifications.get(revision.id); + const previousSpecification = parsedSpecifications.get(previous.id); + if (currentSpecification && previousSpecification) { + comparison = { + kind: "changes", + changes: diffServiceRevisionSpecs( + previousSpecification, + currentSpecification, + serverNames, + ), + }; + } else { + comparison = { kind: "unavailable" }; + } + } + + const rollout = rolloutByRevisionId.get(revision.id); + return { + id: revision.id, + createdAt: revision.createdAt.toISOString(), + comparison, + rollout: rollout ? { id: rollout.id, status: rollout.status } : null, + }; + }, + ); + + const response: ServiceRevisionChangelogResponse = { + revisions: items, + nextCursor: + revisions.length > PAGE_SIZE && pageRevisions.length > 0 + ? encodeCursor({ + createdAt: pageRevisions[pageRevisions.length - 1].cursorCreatedAt, + id: pageRevisions[pageRevisions.length - 1].id, + }) + : null, + }; + + return Response.json(response); +} diff --git a/web/components/builds/build-details.tsx b/web/components/builds/build-details.tsx index dfeef85..6b7aa5b 100644 --- a/web/components/builds/build-details.tsx +++ b/web/components/builds/build-details.tsx @@ -265,7 +265,6 @@ export function BuildDetails({ - Timing
Created diff --git a/web/components/service/details/changelog-history.tsx b/web/components/service/details/changelog-history.tsx new file mode 100644 index 0000000..364db40 --- /dev/null +++ b/web/components/service/details/changelog-history.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { + ArrowRight, + CheckCircle2, + Clock, + GitCommitHorizontal, + Loader2, + RotateCcw, + XCircle, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo } from "react"; +import useSWRInfinite from "swr/infinite"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { RolloutStatus } from "@/db/types"; +import { formatDateTime, formatRelativeTime } from "@/lib/date"; +import { fetcher } from "@/lib/fetcher"; +import type { + ServiceRevisionChangelogItem, + ServiceRevisionChangelogResponse, +} from "@/lib/service-revision-changes"; + +const STATUS_CONFIG: Record< + RolloutStatus, + { label: string; icon: typeof Clock; className: string } +> = { + queued: { label: "Queued", icon: Clock, className: "text-slate-600" }, + in_progress: { + label: "In progress", + icon: Loader2, + className: "text-blue-600", + }, + completed: { + label: "Completed", + icon: CheckCircle2, + className: "text-emerald-600", + }, + failed: { label: "Failed", icon: XCircle, className: "text-red-600" }, + rolled_back: { + label: "Rolled back", + icon: RotateCcw, + className: "text-orange-600", + }, +}; + +function RolloutBadge({ + rollout, + serviceId, + projectSlug, + envName, +}: { + rollout: NonNullable; + serviceId: string; + projectSlug: string; + envName: string; +}) { + const config = STATUS_CONFIG[rollout.status] ?? { + label: rollout.status, + icon: Clock, + className: "text-muted-foreground", + }; + const Icon = config.icon; + + return ( + + } + > + + {config.label} + + ); +} + +function ChangelogSkeleton() { + return ( +
+ {[1, 2, 3].map((item) => ( +
+ + +
+ ))} +
+ ); +} + +function RevisionChanges({ item }: { item: ServiceRevisionChangelogItem }) { + if (item.comparison.kind === "initial") { + return ( +

+ Initial service configuration captured. +

+ ); + } + + if (item.comparison.kind === "unavailable") { + return ( +

+ Changes are unavailable for this revision format. +

+ ); + } + + if (item.comparison.changes.length === 0) { + return ( +

+ No configuration changes — redeploy. +

+ ); + } + + return ( +
+ {item.comparison.changes.map((change) => ( +
+
{change.field}
+
+ {change.from} + + {change.to} +
+
+ ))} +
+ ); +} + +export function ChangelogHistory({ + serviceId, + projectSlug, + envName, +}: { + serviceId: string; + projectSlug: string; + envName: string; +}) { + const { data, error, isLoading, isValidating, mutate, size, setSize } = + useSWRInfinite( + (pageIndex, previousPage) => { + if (previousPage && !previousPage.nextCursor) return null; + const cursor = pageIndex === 0 ? null : previousPage?.nextCursor; + return `/api/services/${serviceId}/revisions${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`; + }, + fetcher, + { + refreshInterval: (pages) => + pages?.some((page) => + page.revisions.some( + (revision) => + revision.rollout?.status === "queued" || + revision.rollout?.status === "in_progress", + ), + ) + ? 3000 + : 0, + revalidateOnFocus: true, + }, + ); + + const revisions = useMemo(() => { + const byId = new Map(); + for (const page of data ?? []) { + for (const revision of page.revisions) byId.set(revision.id, revision); + } + return [...byId.values()]; + }, [data]); + const hasMore = data?.[data.length - 1]?.nextCursor != null; + const isLoadingMore = isValidating && Boolean(data?.[size - 1] === undefined); + + if (isLoading) return ; + + if (error) { + return ( + + + + + Unable to load changelog + + Revision history could not be loaded. Try again. + + + + ); + } + + return ( +
+ {revisions.length === 0 ? ( + + + + + No revisions yet + + Deploy this service to create its first revision. + + + ) : ( +
+ {revisions.map((revision) => ( +
+
+
+
+ {revision.comparison.kind === "initial" + ? "Initial revision" + : revision.comparison.kind === "changes" && + revision.comparison.changes.length === 0 + ? "Redeployed" + : "Configuration updated"} +
+
+ {formatRelativeTime(revision.createdAt)} ·{" "} + {revision.id.slice(0, 8)} +
+
+ {revision.rollout ? ( + + ) : null} +
+ +
+ ))} +
+ )} + + {hasMore ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index b9cd6f4..a5d584b 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -316,7 +316,7 @@ export function ServiceMetricsPanel({ margin={{ top: 8, right: fixedMode ? 48 : 4, - left: fixedMode ? 0 : getYAxisMargin(chartMode), + left: 0, bottom: 0, }} > @@ -338,6 +338,7 @@ export function ServiceMetricsPanel({ className="text-xs" /> @@ -1257,13 +1258,6 @@ function formatRateTick(value: number): string { return value.toFixed(1); } -function getYAxisMargin(mode: ServiceChartMode): number { - if (mode === "traffic") return -4; - if (mode === "latency") return -12; - if (mode === "resources") return -24; - return -20; -} - function formatRequestCount(value: number): string { if (!Number.isFinite(value)) return "-"; diff --git a/web/components/service/service-canvas.tsx b/web/components/service/service-canvas.tsx index 1e82549..c5aa536 100644 --- a/web/components/service/service-canvas.tsx +++ b/web/components/service/service-canvas.tsx @@ -856,7 +856,7 @@ export function ServiceCanvas({
-
+
{ diff --git a/web/db/schema.ts b/web/db/schema.ts index 5727e28..4e158cf 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -569,6 +569,11 @@ export const serviceRevisions = pgTable( table.serviceId, ), index("service_revisions_service_id_idx").on(table.serviceId), + index("service_revisions_service_created_id_idx").on( + table.serviceId, + table.createdAt, + table.id, + ), ], ); diff --git a/web/lib/service-config.ts b/web/lib/service-config.ts index ece73e8..22d5618 100644 --- a/web/lib/service-config.ts +++ b/web/lib/service-config.ts @@ -401,8 +401,8 @@ export function diffConfigs( }); } - const deployedCpu = deployed.resourceLimits?.cpuCores; - const currentCpu = current.resourceLimits?.cpuCores; + const deployedCpu = deployed.resourceLimits?.cpuCores ?? null; + const currentCpu = current.resourceLimits?.cpuCores ?? null; if (deployedCpu !== currentCpu) { changes.push({ field: "CPU limit", @@ -411,8 +411,8 @@ export function diffConfigs( }); } - const deployedMemory = deployed.resourceLimits?.memoryMb; - const currentMemory = current.resourceLimits?.memoryMb; + const deployedMemory = deployed.resourceLimits?.memoryMb ?? null; + const currentMemory = current.resourceLimits?.memoryMb ?? null; if (deployedMemory !== currentMemory) { changes.push({ field: "Memory limit", diff --git a/web/lib/service-revision-changes.ts b/web/lib/service-revision-changes.ts new file mode 100644 index 0000000..45516ed --- /dev/null +++ b/web/lib/service-revision-changes.ts @@ -0,0 +1,301 @@ +import { z } from "zod"; +import type { RolloutStatus } from "@/db/types"; +import type { + ServiceRevisionHealthCheck, + ServiceRevisionPort, + ServiceRevisionSpec, +} from "@/lib/service-revision-spec"; + +const serviceRevisionSpecSchema = z.strictObject({ + schemaVersion: z.literal(1), + image: z.string(), + hostname: z.string(), + stateful: z.boolean(), + serverless: z.strictObject({ + enabled: z.boolean(), + sleepAfterSeconds: z.number(), + wakeTimeoutSeconds: z.number(), + }), + healthCheck: z + .strictObject({ + cmd: z.string(), + interval: z.number(), + timeout: z.number(), + retries: z.number(), + startPeriod: z.number(), + }) + .nullable(), + startCommand: z.string().nullable(), + resourceLimits: z.strictObject({ + cpuCores: z.number().nullable(), + memoryMb: z.number().nullable(), + }), + placements: z.array( + z.strictObject({ serverId: z.string(), count: z.number() }), + ), + ports: z.array( + z.strictObject({ + containerPort: z.number(), + isPublic: z.boolean(), + domain: z.string().nullable(), + protocol: z.enum(["http", "tcp", "udp"]), + externalPort: z.number().nullable(), + tlsPassthrough: z.boolean(), + }), + ), + secrets: z.array( + z.strictObject({ + key: z.string(), + encryptedValue: z.string(), + updatedAt: z.string(), + }), + ), + volumes: z.array( + z.strictObject({ name: z.string(), containerPath: z.string() }), + ), +}); + +export type ServiceRevisionChange = { + field: string; + from: string; + to: string; +}; + +export type ServiceRevisionComparison = + | { kind: "initial" } + | { kind: "changes"; changes: ServiceRevisionChange[] } + | { kind: "unavailable" }; + +export type ServiceRevisionChangelogItem = { + id: string; + createdAt: string; + comparison: ServiceRevisionComparison; + rollout: { + id: string; + status: RolloutStatus; + } | null; +}; + +export type ServiceRevisionChangelogResponse = { + revisions: ServiceRevisionChangelogItem[]; + nextCursor: string | null; +}; + +export function parseServiceRevisionSpec(value: unknown): ServiceRevisionSpec { + return serviceRevisionSpecSchema.parse(value); +} + +function compareStrings(a: string, b: string): number { + return a.localeCompare(b, "en"); +} + +function enabled(value: boolean): string { + return value ? "Enabled" : "Disabled"; +} + +function healthCheckDescription( + healthCheck: ServiceRevisionHealthCheck, +): string { + return `${healthCheck.cmd} (interval ${healthCheck.interval}s, timeout ${healthCheck.timeout}s, retries ${healthCheck.retries}, start period ${healthCheck.startPeriod}s)`; +} + +function portIdentity(port: ServiceRevisionPort): string { + return `${port.containerPort}/${port.protocol}/${port.domain ?? "(none)"}`; +} + +function portLabel(port: ServiceRevisionPort): string { + return `${port.containerPort}/${port.protocol}`; +} + +function portDescription(port: ServiceRevisionPort): string { + return [ + `container ${port.containerPort}`, + `protocol ${port.protocol}`, + port.isPublic ? "public" : "internal", + `domain ${port.domain ?? "(none)"}`, + `external ${port.externalPort ?? "(default)"}`, + `TLS passthrough ${enabled(port.tlsPassthrough).toLowerCase()}`, + ].join(", "); +} + +/** Compare two immutable v1 specifications without requiring browser APIs. */ +export function diffServiceRevisionSpecs( + previous: ServiceRevisionSpec, + current: ServiceRevisionSpec, + serverNames: ReadonlyMap = new Map(), +): ServiceRevisionChange[] { + const changes: ServiceRevisionChange[] = []; + const add = (field: string, from: string, to: string) => { + if (from !== to) changes.push({ field, from, to }); + }; + + add("Image", previous.image, current.image); + add("Hostname", previous.hostname, current.hostname); + add( + "Service type", + previous.stateful ? "Stateful" : "Stateless", + current.stateful ? "Stateful" : "Stateless", + ); + add( + "Serverless", + enabled(previous.serverless.enabled), + enabled(current.serverless.enabled), + ); + add( + "Serverless sleep timeout", + `${previous.serverless.sleepAfterSeconds}s`, + `${current.serverless.sleepAfterSeconds}s`, + ); + add( + "Serverless wake timeout", + `${previous.serverless.wakeTimeoutSeconds}s`, + `${current.serverless.wakeTimeoutSeconds}s`, + ); + + if (previous.healthCheck === null || current.healthCheck === null) { + add( + "Health check", + previous.healthCheck + ? healthCheckDescription(previous.healthCheck) + : "(none)", + current.healthCheck + ? healthCheckDescription(current.healthCheck) + : "(none)", + ); + } else { + add( + "Health check command", + previous.healthCheck.cmd, + current.healthCheck.cmd, + ); + add( + "Health check interval", + `${previous.healthCheck.interval}s`, + `${current.healthCheck.interval}s`, + ); + add( + "Health check timeout", + `${previous.healthCheck.timeout}s`, + `${current.healthCheck.timeout}s`, + ); + add( + "Health check retries", + String(previous.healthCheck.retries), + String(current.healthCheck.retries), + ); + add( + "Health check start period", + `${previous.healthCheck.startPeriod}s`, + `${current.healthCheck.startPeriod}s`, + ); + } + + add( + "Start command", + previous.startCommand ?? "(default)", + current.startCommand ?? "(default)", + ); + add( + "CPU limit", + previous.resourceLimits.cpuCores === null + ? "(no limit)" + : `${previous.resourceLimits.cpuCores} cores`, + current.resourceLimits.cpuCores === null + ? "(no limit)" + : `${current.resourceLimits.cpuCores} cores`, + ); + add( + "Memory limit", + previous.resourceLimits.memoryMb === null + ? "(no limit)" + : `${previous.resourceLimits.memoryMb} MB`, + current.resourceLimits.memoryMb === null + ? "(no limit)" + : `${current.resourceLimits.memoryMb} MB`, + ); + + const previousPlacements = new Map( + previous.placements.map((placement) => [placement.serverId, placement]), + ); + const currentPlacements = new Map( + current.placements.map((placement) => [placement.serverId, placement]), + ); + for (const serverId of [ + ...new Set([...previousPlacements.keys(), ...currentPlacements.keys()]), + ].sort(compareStrings)) { + const before = previousPlacements.get(serverId); + const after = currentPlacements.get(serverId); + const serverName = serverNames.get(serverId)?.trim(); + add( + serverName + ? `${serverName} replicas` + : `Deleted server (${serverId.slice(0, 8)}) replicas`, + before ? `${before.count} replicas` : "(none)", + after ? `${after.count} replicas` : "(removed)", + ); + } + + const previousPorts = new Map( + previous.ports.map((port) => [portIdentity(port), port]), + ); + const currentPorts = new Map( + current.ports.map((port) => [portIdentity(port), port]), + ); + for (const identity of [ + ...new Set([...previousPorts.keys(), ...currentPorts.keys()]), + ].sort(compareStrings)) { + const before = previousPorts.get(identity); + const after = currentPorts.get(identity); + const port = after ?? before; + add( + port ? `Port ${portLabel(port)}` : "Port", + before ? portDescription(before) : "(none)", + after ? portDescription(after) : "(removed)", + ); + } + + const previousSecrets = new Map( + previous.secrets.map((secret) => [secret.key, secret]), + ); + const currentSecrets = new Map( + current.secrets.map((secret) => [secret.key, secret]), + ); + for (const key of [ + ...new Set([...previousSecrets.keys(), ...currentSecrets.keys()]), + ].sort(compareStrings)) { + const before = previousSecrets.get(key); + const after = currentSecrets.get(key); + if (!before && after) + changes.push({ field: "Secret", from: "(none)", to: `${key} (added)` }); + else if (before && !after) + changes.push({ field: "Secret", from: key, to: "(removed)" }); + else if ( + before && + after && + (before.encryptedValue !== after.encryptedValue || + before.updatedAt !== after.updatedAt) + ) { + changes.push({ field: "Secret", from: key, to: `${key} (updated)` }); + } + } + + const previousVolumes = new Map( + previous.volumes.map((volume) => [volume.name, volume]), + ); + const currentVolumes = new Map( + current.volumes.map((volume) => [volume.name, volume]), + ); + for (const name of [ + ...new Set([...previousVolumes.keys(), ...currentVolumes.keys()]), + ].sort(compareStrings)) { + const before = previousVolumes.get(name); + const after = currentVolumes.get(name); + add( + `Volume ${name}`, + before?.containerPath ?? "(none)", + after?.containerPath ?? "(removed)", + ); + } + + return changes; +} diff --git a/web/tests/service-config.test.ts b/web/tests/service-config.test.ts index 8609fed..10a5434 100644 --- a/web/tests/service-config.test.ts +++ b/web/tests/service-config.test.ts @@ -66,6 +66,15 @@ describe("service config", () => { ]); }); + it("does not report null and omitted resource limits as pending", () => { + const deployed = deployedConfig({ + resourceLimits: { cpuCores: null, memoryMb: null }, + }); + const current = deployedConfig(); + + expect(diffConfigs(deployed, current)).toEqual([]); + }); + it("reports serverless changes as pending config", () => { const changes = diffConfigs(deployedConfig(), { source: { type: "image", image: "nginx" }, diff --git a/web/tests/service-revision-changes.test.ts b/web/tests/service-revision-changes.test.ts new file mode 100644 index 0000000..ba956c5 --- /dev/null +++ b/web/tests/service-revision-changes.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { diffServiceRevisionSpecs } from "@/lib/service-revision-changes"; +import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; + +function spec(): ServiceRevisionSpec { + return { + schemaVersion: 1, + image: "app:v1", + hostname: "app", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 60, + }, + healthCheck: { + cmd: "curl /health", + interval: 10, + timeout: 5, + retries: 3, + startPeriod: 30, + }, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placements: [{ serverId: "server-a", count: 1 }], + ports: [ + { + containerPort: 80, + protocol: "http", + isPublic: false, + domain: null, + externalPort: null, + tlsPassthrough: false, + }, + ], + secrets: [ + { key: "TOKEN", encryptedValue: "cipher-one", updatedAt: "2026-01-01" }, + ], + volumes: [{ name: "data", containerPath: "/data" }], + }; +} + +describe("diffServiceRevisionSpecs", () => { + it("reports representative scalar and health check changes", () => { + const previous = spec(); + const current = structuredClone(previous); + current.image = "app:v2"; + current.hostname = "new-app"; + current.stateful = true; + current.serverless = { + enabled: true, + sleepAfterSeconds: 600, + wakeTimeoutSeconds: 90, + }; + current.healthCheck = { + cmd: "wget /ready", + interval: 20, + timeout: 8, + retries: 5, + startPeriod: 40, + }; + current.startCommand = "npm start"; + current.resourceLimits = { cpuCores: 2, memoryMb: 512 }; + + expect(diffServiceRevisionSpecs(previous, current)).toEqual( + expect.arrayContaining([ + { field: "Image", from: "app:v1", to: "app:v2" }, + { field: "Health check start period", from: "30s", to: "40s" }, + { field: "Start command", from: "(default)", to: "npm start" }, + { field: "CPU limit", from: "(no limit)", to: "2 cores" }, + { field: "Memory limit", from: "(no limit)", to: "512 MB" }, + ]), + ); + }); + + it("compares collections and includes every port property", () => { + const previous = spec(); + const current = structuredClone(previous); + current.placements[0].count = 2; + current.volumes[0].containerPath = "/mnt/data"; + current.ports[0] = { + containerPort: 80, + protocol: "http", + isPublic: true, + domain: "app.test", + externalPort: 443, + tlsPassthrough: true, + }; + current.ports.push({ + containerPort: 80, + protocol: "tcp", + isPublic: false, + domain: null, + externalPort: 8080, + tlsPassthrough: false, + }); + + const changes = diffServiceRevisionSpecs( + previous, + current, + new Map([["server-a", "Sydney"]]), + ); + expect(changes).toContainEqual({ + field: "Sydney replicas", + from: "1 replicas", + to: "2 replicas", + }); + expect(changes).toContainEqual({ + field: "Volume data", + from: "/data", + to: "/mnt/data", + }); + expect(changes).toContainEqual({ + field: "Port 80/http", + from: "container 80, protocol http, internal, domain (none), external (default), TLS passthrough disabled", + to: "(removed)", + }); + expect(changes).toContainEqual({ + field: "Port 80/http", + from: "(none)", + to: "container 80, protocol http, public, domain app.test, external 443, TLS passthrough enabled", + }); + expect(changes).toContainEqual( + expect.objectContaining({ field: "Port 80/tcp" }), + ); + }); + + it("identifies deleted placement servers without exposing full IDs", () => { + const previous = spec(); + const current = structuredClone(previous); + current.placements[0].count = 2; + + expect(diffServiceRevisionSpecs(previous, current)).toContainEqual({ + field: "Deleted server (server-a) replicas", + from: "1 replicas", + to: "2 replicas", + }); + }); + + it("never exposes secret ciphertext while detecting additions, updates, and removals", () => { + const previous = spec(); + previous.secrets.push({ + key: "OLD", + encryptedValue: "do-not-leak-old", + updatedAt: "1", + }); + const current = structuredClone(previous); + current.secrets = [ + { + key: "TOKEN", + encryptedValue: "do-not-leak-new", + updatedAt: "2026-01-01", + }, + { key: "NEW", encryptedValue: "do-not-leak-added", updatedAt: "1" }, + ]; + + const changes = diffServiceRevisionSpecs(previous, current); + expect(changes).toEqual([ + { field: "Secret", from: "(none)", to: "NEW (added)" }, + { field: "Secret", from: "OLD", to: "(removed)" }, + { field: "Secret", from: "TOKEN", to: "TOKEN (updated)" }, + ]); + expect(JSON.stringify(changes)).not.toContain("do-not-leak"); + }); + + it("ignores canonical-equivalent array ordering and identical specs", () => { + const previous = spec(); + previous.placements.push({ serverId: "server-b", count: 2 }); + previous.ports.push({ + containerPort: 53, + protocol: "udp", + isPublic: false, + domain: null, + externalPort: null, + tlsPassthrough: false, + }); + previous.secrets.push({ + key: "OTHER", + encryptedValue: "cipher-two", + updatedAt: "2", + }); + previous.volumes.push({ name: "logs", containerPath: "/logs" }); + const reordered = structuredClone(previous); + reordered.placements.reverse(); + reordered.ports.reverse(); + reordered.secrets.reverse(); + reordered.volumes.reverse(); + + expect(diffServiceRevisionSpecs(previous, reordered)).toEqual([]); + expect(diffServiceRevisionSpecs(previous, previous)).toEqual([]); + }); +}); diff --git a/web/tests/service-revisions-route.test.ts b/web/tests/service-revisions-route.test.ts new file mode 100644 index 0000000..7286125 --- /dev/null +++ b/web/tests/service-revisions-route.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServiceRevisionSpec } from "@/lib/service-revision-spec"; + +const mocks = vi.hoisted(() => { + const queryResults: unknown[][] = []; + + function createQuery(result: unknown[]) { + const query = { + from: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + limit: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + + return { + queryResults, + db: { + select: vi.fn(() => createQuery(queryResults.shift() ?? [])), + }, + requireRequestSession: vi.fn(), + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/lib/api-auth", () => ({ + requireRequestSession: mocks.requireRequestSession, +})); + +import { GET } from "@/app/api/services/[id]/revisions/route"; + +function revisionSpec( + image = "app:v1", + encryptedValue = "cipher", +): ServiceRevisionSpec { + return { + schemaVersion: 1, + image, + hostname: "app", + stateful: false, + serverless: { + enabled: false, + sleepAfterSeconds: 300, + wakeTimeoutSeconds: 300, + }, + healthCheck: null, + startCommand: null, + resourceLimits: { cpuCores: null, memoryMb: null }, + placements: [{ serverId: "server-1", count: 1 }], + ports: [], + secrets: [{ key: "TOKEN", encryptedValue, updatedAt: "2026-01-01" }], + volumes: [], + }; +} + +function request(cursor?: string) { + const url = new URL("http://localhost/api/services/service-1/revisions"); + if (cursor) url.searchParams.set("cursor", cursor); + return GET(new Request(url), { + params: Promise.resolve({ id: "service-1" }), + }); +} + +describe("service revisions route", () => { + beforeEach(() => { + mocks.queryResults.length = 0; + mocks.db.select.mockClear(); + mocks.requireRequestSession.mockReset(); + mocks.requireRequestSession.mockResolvedValue({ + ok: true, + session: { user: { id: "user-1" } }, + }); + }); + + it("requires authentication", async () => { + mocks.requireRequestSession.mockResolvedValue({ + ok: false, + response: Response.json({ message: "Unauthorized" }, { status: 401 }), + }); + + const response = await request(); + + expect(response.status).toBe(401); + expect(mocks.db.select).not.toHaveBeenCalled(); + }); + + it("rejects malformed cursors before querying the service", async () => { + const response = await request("not-a-cursor"); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + message: "Invalid revision cursor", + }); + expect(mocks.db.select).not.toHaveBeenCalled(); + }); + + it("rejects parseable timestamps that PostgreSQL may not accept", async () => { + const cursor = Buffer.from( + JSON.stringify({ + createdAt: "Mon Jul 13 2026 02:00:00 GMT+0000", + id: "revision-1", + }), + ).toString("base64url"); + + const response = await request(cursor); + + expect(response.status).toBe(400); + expect(mocks.db.select).not.toHaveBeenCalled(); + }); + + it("returns safe changes and rollout metadata", async () => { + mocks.queryResults.push( + [{ id: "service-1" }], + [ + { + id: "revision-2", + createdAt: new Date("2026-07-13T02:00:00Z"), + cursorCreatedAt: "2026-07-13 02:00:00+00", + specification: revisionSpec("app:v2", "secret-new"), + }, + { + id: "revision-1", + createdAt: new Date("2026-07-13T01:00:00Z"), + cursorCreatedAt: "2026-07-13 01:00:00+00", + specification: revisionSpec("app:v1", "secret-old"), + }, + ], + [{ id: "server-1", name: "Sydney" }], + [ + { + id: "rollout-2", + serviceRevisionId: "revision-2", + status: "completed", + createdAt: new Date("2026-07-13T02:00:00Z"), + }, + ], + ); + + const response = await request(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.revisions[0]).toMatchObject({ + id: "revision-2", + rollout: { id: "rollout-2", status: "completed" }, + comparison: { + kind: "changes", + changes: expect.arrayContaining([ + { field: "Image", from: "app:v1", to: "app:v2" }, + { field: "Secret", from: "TOKEN", to: "TOKEN (updated)" }, + ]), + }, + }); + expect(body.revisions[1].comparison).toEqual({ kind: "initial" }); + expect(JSON.stringify(body)).not.toContain("secret-new"); + expect(JSON.stringify(body)).not.toContain("secret-old"); + expect(JSON.stringify(body)).not.toContain("specification"); + }); + + it("uses the extra revision as the page boundary comparison", async () => { + const revisions = Array.from({ length: 26 }, (_, index) => ({ + id: `revision-${String(26 - index).padStart(2, "0")}`, + createdAt: new Date(Date.UTC(2026, 6, 13, 2, 0, 26 - index)), + cursorCreatedAt: `2026-07-13 02:00:${String(26 - index).padStart(2, "0")}.123456+00`, + specification: revisionSpec(`app:v${26 - index}`), + })); + mocks.queryResults.push([{ id: "service-1" }], revisions, [], []); + + const response = await request(); + const body = await response.json(); + + expect(body.revisions).toHaveLength(25); + expect(body.nextCursor).toEqual(expect.any(String)); + expect( + JSON.parse(Buffer.from(body.nextCursor, "base64url").toString("utf8")), + ).toMatchObject({ createdAt: expect.stringContaining(".123456") }); + expect(body.revisions[24].comparison).toMatchObject({ + kind: "changes", + changes: [{ field: "Image", from: "app:v1", to: "app:v2" }], + }); + }); + + it("rejects malformed v1 specifications without exposing their values", async () => { + mocks.queryResults.push( + [{ id: "service-1" }], + [ + { + id: "revision-2", + createdAt: new Date("2026-07-13T02:00:00Z"), + cursorCreatedAt: "2026-07-13 02:00:00+00", + specification: { + ...revisionSpec(), + image: { encryptedValue: "must-not-leak" }, + }, + }, + { + id: "revision-1", + createdAt: new Date("2026-07-13T01:00:00Z"), + cursorCreatedAt: "2026-07-13 01:00:00+00", + specification: revisionSpec(), + }, + ], + [], + [], + ); + + const response = await request(); + const body = await response.json(); + + expect(body.revisions[0].comparison).toEqual({ kind: "unavailable" }); + expect(JSON.stringify(body)).not.toContain("must-not-leak"); + }); +});