Skip to content

Commit

Permalink
add deployments list stats page
Browse files Browse the repository at this point in the history
  • Loading branch information
jsbroks committed Jan 21, 2025
1 parent 0673b0c commit baca8b0
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@

import Link from "next/link";
import { usePathname } from "next/navigation";
import {
IconCube,
IconFilter,
IconList,
IconPlus,
IconTarget,
} from "@tabler/icons-react";
import { IconCube, IconFilter, IconList, IconPlus } from "@tabler/icons-react";

import { Badge } from "@ctrlplane/ui/badge";
import { Button } from "@ctrlplane/ui/button";
Expand Down Expand Up @@ -62,7 +56,7 @@ export default function ResourceLayout({
<>
<div className="flex items-center gap-2 border-b px-2">
<div className="flex items-center gap-2 p-3">
<IconTarget className="h-4 w-4" /> Resources
<IconCube className="h-4 w-4" /> Resources
</div>
<div className="flex-grow">
<NavigationMenu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,4 @@ import { DependenciesGettingStarted } from "./DependenciesGettingStarted";

export default function Dependencies() {
return <DependenciesGettingStarted />;

// const workspace = await api.workspace.bySlug(params.workspaceSlug);
// if (workspace == null) notFound();
// const deployments = await api.deployment.byWorkspaceId(workspace.id);

// if (
// deployments.length === 0 ||
// deployments.some((d) => d.latestActiveReleases != null)
// )
// return <DependenciesGettingStarted />;

// const transformedDeployments = deployments.map((deployment) => ({
// ...deployment,
// latestActiveRelease: deployment.latestActiveReleases && {
// id: deployment.latestActiveReleases.id,
// version: deployment.latestActiveReleases.version,
// },
// }));

// return (
// <div className="h-full">
// <Diagram deployments={transformedDeployments} />
// </div>
// );
}
170 changes: 170 additions & 0 deletions apps/webservice/src/app/[workspaceSlug]/(app)/deployments/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"use client";

import { formatDistanceToNowStrict, subWeeks } from "date-fns";
import prettyMilliseconds from "pretty-ms";

import { Card } from "@ctrlplane/ui/card";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@ctrlplane/ui/table";

import { api } from "~/trpc/react";

const DeploymentHistoryGraph: React.FC<{ name: string; repo: string }> = () => {
const history = [
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: null },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
{ successRate: 100 },
];

return (
<div className="flex h-[30px] items-center gap-1">
{history.map(({ successRate }, j) => (
<div
key={j}
className="relative h-full w-1.5 overflow-hidden rounded-sm"
>
{successRate == null ? (
<div className="absolute bottom-0 h-full w-full bg-neutral-700" />
) : (
<>
<div className="absolute bottom-0 h-full w-full bg-red-500" />
<div
className="absolute bottom-0 w-full bg-green-500"
style={{ height: `${successRate}%` }}
/>
</>
)}
</div>
))}
</div>
);
};

export const DeploymentsCard: React.FC<{ workspaceId: string }> = ({
workspaceId,
}) => {
const today = new Date();
const startDate = subWeeks(today, 2);
const deployments = api.deployment.stats.byWorkspaceId.useQuery({
workspaceId,
startDate,
endDate: today,
});

return (
<Card>
<div>
<div className="relative w-full overflow-auto">
<Table>
<TableHeader>
<TableRow className="h-16 hover:bg-transparent">
<TableHead className="p-4">Workflow</TableHead>
<TableHead className="p-4">History (30 days)</TableHead>
<TableHead className="w-[75px] p-4 xl:w-[150px]">
P50 Duration
</TableHead>
<TableHead className="w-[75px] p-4 xl:w-[150px]">
P90 Duration
</TableHead>

<TableHead className="w-[140px] p-4">Success Rate</TableHead>
<TableHead className="hidden p-4 xl:table-cell xl:w-[120px]">
Last Run
</TableHead>
</TableRow>
</TableHeader>

<TableBody>
{deployments.data?.map((deployment) => (
<tr key={deployment.id} className="border-b">
<td className="p-4 align-middle">
<div className="flex items-center gap-2">
{deployment.name}
</div>
<div className="text-xs text-muted-foreground">
{deployment.systemName} / {deployment.name}
</div>
</td>

<td className="p-4 align-middle">
<DeploymentHistoryGraph
name={deployment.name}
repo={deployment.systemName}
/>
</td>

<td className="p-4 ">
{prettyMilliseconds(Math.round(deployment.p50) * 1000)}
</td>
<td className="p-4 ">
{prettyMilliseconds(Math.round(deployment.p90) * 1000)}
</td>

<td className="p-4">
<div className="flex items-center gap-2">
<div className="h-2 w-full rounded-full bg-neutral-800">
<div
className="h-full rounded-full bg-white transition-all"
style={{
width: `${(deployment.totalSuccess / deployment.totalJobs) * 100}%`,
}}
/>
</div>
<div className="w-[75px] text-right">
{(
(deployment.totalSuccess / deployment.totalJobs) *
100
).toFixed(0)}
%
</div>
</div>
</td>

<td className="hidden p-4 align-middle xl:table-cell">
<div>
{formatDistanceToNowStrict(new Date(), {
addSuffix: false,
})}
</div>
</td>
</tr>
))}
</TableBody>
</Table>
</div>
</div>
</Card>
);
};
41 changes: 41 additions & 0 deletions apps/webservice/src/app/[workspaceSlug]/(app)/deployments/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { IconRocket } from "@tabler/icons-react";

import { api } from "~/trpc/server";
import { DeploymentsCard } from "./Card";

export const metadata: Metadata = {
title: "Deployments | Ctrlplane",
};

export default async function DeploymentsPage({
params,
}: {
params: { workspaceSlug: string };
}) {
const { workspaceSlug } = params;
const workspace = await api.workspace.bySlug(workspaceSlug);
if (workspace == null) notFound();
return (
<div>
<div className="flex items-center gap-2 border-b px-2">
<div className="flex items-center gap-2 p-3">
<IconRocket className="h-4 w-4" /> Deployments
</div>
</div>

<div className="container m-8 mx-auto">
<div>
<div className="mb-4">
<h2 className="text-xl font-semibold">Billing</h2>
<p className="text-sm text-muted-foreground">
Monitor your usage costs, credits, and billing status
</p>
</div>
</div>
<DeploymentsCard workspaceId={workspace.id} />
</div>
</div>
);
}
82 changes: 82 additions & 0 deletions packages/api/src/router/deployment-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { z } from "zod";

import { and, count, eq, gte, lte, max, sql } from "@ctrlplane/db";
import {
deployment,
job,
release,
releaseJobTrigger,
system,
} from "@ctrlplane/db/schema";
import { Permission } from "@ctrlplane/validators/auth";

import { createTRPCRouter, protectedProcedure } from "../trpc";

export const deploymentStatsRouter = createTRPCRouter({
byWorkspaceId: protectedProcedure
.meta({
authorizationCheck: ({ canUser, input }) =>
canUser
.perform(Permission.DeploymentList)
.on({ type: "workspace", id: input.workspaceId }),
})
.input(
z.object({
workspaceId: z.string().uuid(),
startDate: z.date(),
endDate: z.date(),
}),
)
.query(({ ctx, input }) =>
ctx.db
.select({
id: deployment.id,
name: deployment.name,
slug: deployment.slug,
systemId: system.id,
systemSlug: system.slug,
systemName: system.name,
lastRunAt: max(job.createdAt),
totalJobs: count(job.id),
totalSuccess: sql<number>`
(COUNT(*) FILTER (WHERE ${job.status} = 'completed') / COUNT(*)) * 100
`,

p50: sql<number>`
percentile_cont(0.5) within group (order by
EXTRACT(EPOCH FROM (${job.completedAt} - ${job.startedAt}))
)
`,

p90: sql<number>`
percentile_cont(0.9) within group (order by
EXTRACT(EPOCH FROM (${job.completedAt} - ${job.startedAt}))
)
`,
})
.from(deployment)
.innerJoin(release, eq(release.deploymentId, deployment.id))
.innerJoin(
releaseJobTrigger,
eq(releaseJobTrigger.releaseId, release.id),
)
.innerJoin(job, eq(job.id, releaseJobTrigger.jobId))
.innerJoin(system, eq(system.id, deployment.systemId))
.where(
and(
eq(system.workspaceId, input.workspaceId),
gte(job.createdAt, input.startDate),
lte(job.createdAt, input.endDate),
),
)
.groupBy(
deployment.id,
deployment.name,
deployment.slug,
system.id,
system.name,
system.slug,
)
.limit(100),
),
});
10 changes: 9 additions & 1 deletion packages/api/src/router/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { Permission } from "@ctrlplane/validators/auth";
import { JobStatus } from "@ctrlplane/validators/jobs";

import { createTRPCRouter, protectedProcedure } from "../trpc";
import { deploymentStatsRouter } from "./deployment-stats";
import { deploymentVariableRouter } from "./deployment-variable";

const releaseChannelRouter = createTRPCRouter({
Expand Down Expand Up @@ -704,6 +705,13 @@ export const deploymentRouter = createTRPCRouter({
.from(deployment)
.innerJoin(system, eq(system.id, deployment.systemId))
.where(eq(system.workspaceId, input))
.then((r) => r.map((row) => row.deployment)),
.then((r) =>
r.map((row) => ({
...row.deployment,
system: row.system,
})),
),
),

stats: deploymentStatsRouter,
});

0 comments on commit baca8b0

Please sign in to comment.