Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<ChangelogHistory
serviceId={service.id}
projectSlug={projectSlug}
envName={envName}
/>
);
}
26 changes: 6 additions & 20 deletions web/app/(dashboard)/layout-client.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 = (
Expand All @@ -43,8 +36,8 @@ function DashboardHeader({
const showEllipsis = breadcrumbs.length > 2;

return (
<header className="border-b">
<div className="container max-w-full mx-auto px-4 h-14 flex items-center justify-between">
<header className="h-14 border-b">
<div className="container max-w-full mx-auto px-4 h-full flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard" className="flex items-center">
<Image
Expand Down Expand Up @@ -119,11 +112,7 @@ function DashboardHeader({
>
<User className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-40"
>
<DropdownMenuContent align="end" sideOffset={8} className="w-40">
<DropdownMenuGroup>
<DropdownMenuLabel>
<span className="block truncate text-sm font-semibold">
Expand Down Expand Up @@ -183,10 +172,7 @@ export function DashboardLayoutClient({
return (
<BreadcrumbDataProvider>
<div className="min-h-screen">
<DashboardHeader
email={session.user.email}
name={session.user.name}
/>
<DashboardHeader email={session.user.email} name={session.user.name} />
<OfflineServersBanner />
<main>{children}</main>
<Toaster />
Expand Down
216 changes: 216 additions & 0 deletions web/app/api/services/[id]/revisions/route.ts
Original file line number Diff line number Diff line change
@@ -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<RevisionCursor>;
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<string>`${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<string, ServiceRevisionSpec>();
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);
}
1 change: 0 additions & 1 deletion web/components/builds/build-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,6 @@ export function BuildDetails({

<Item variant="outline">
<ItemContent>
<ItemTitle>Timing</ItemTitle>
<ItemDescription as="div" className="space-y-1">
<div className="flex justify-between">
<span>Created</span>
Expand Down
Loading
Loading