Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/api/src/modules/deployments/deployment.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function list(c: Context) {
page: Number(c.req.query("page") ?? 1), perPage: Number(c.req.query("perPage") ?? 50),
status: c.req.query("status") as import("@repo/core").DeploymentHistoryFilter | undefined,
search: c.req.query("search"),
days: c.req.query("days") ? Number(c.req.query("days")) : undefined,
});
applyOperationContext(c, result.context);
return c.json({ success: true, ...result.data });
Expand Down
32 changes: 32 additions & 0 deletions apps/api/test/modules/deployments/deployment-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,35 @@ describe("deployment history through the shared engine and real database", () =>
expect(() => parseInput(ListDeploymentsSchema, { status: "bogus" })).toThrow();
});
});

describe("deployment monitoring aggregates", () => {
it("summarizes the full UTC window across pages without counting cancellations as failures", async () => {
const org = await seedOrg();
const project = await seedProject(org.organizationId);
const other = await seedOrg();
const otherProject = await seedProject(other.organizationId);
const now = new Date();
for (let i = 0; i < 23; i++) await seedDeployment(project, { status: "ready", createdAt: now, buildDurationMs: i === 0 ? 1000 : 3000 });
const buildingProject = await seedProject(org.organizationId);
for (const status of ["no_changes", "failed", "partial_failure", "action_required", "rejected", "cancelled", "queued", "building"]) {
await seedDeployment(status === "building" ? buildingProject : project, { status, createdAt: now });
}
await seedDeployment(project, { status: "failed", createdAt: new Date(now.getTime() - 40 * 86400000) });
await seedDeployment(otherProject, { status: "failed", createdAt: now });
const result = await listDeployments(org.organizationId, { days: 7, status: "failed", perPage: 1 });
expect(result.rows).toHaveLength(1);
expect(result.total).toBe(4);
expect(result.summary).toMatchObject({ total: 31, success: 24, failed: 4, active: 2, canceled: 1, avgBuildDurationMs: Math.round(67000 / 23) });
expect(result.summary?.daily).toHaveLength(7);
expect(result.summary?.daily.reduce((n, d) => n + d.total, 0)).toBe(31);
expect(result.summary?.projects).toEqual([expect.objectContaining({ projectId: project.id, total: 30, failed: 4 }), expect.objectContaining({ projectId: buildingProject.id, total: 1, active: 1 })]);
const active = await listDeployments(org.organizationId, { days: 7, status: "active" });
expect(active.rows.map(d => d.status).sort()).toEqual(["building", "queued"]);
const empty = await listDeployments(org.organizationId, { days: 7, search: "no-matching-release" });
expect(empty.summary).toMatchObject({ total: 0, avgBuildDurationMs: null, projects: [] });
expect(empty.summary?.daily.every(d => d.total === 0)).toBe(true);
expect((await listDeployments(org.organizationId, {})).summary).toBeUndefined();
expect(() => parseInput(ListDeploymentsSchema, { days: 91 })).toThrow();
expect(() => parseInput(ListDeploymentsSchema, { days: 0 })).toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"use client";

import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { DeploymentHistoryFilter, DeploymentHistorySummary } from "@repo/core";
import { useI18n, interpolate } from "@/components/i18n-provider";
import { Button } from "@/components/ui/button";
import { CHART_TOOLTIP_STYLE, CHART_TOOLTIP_LABEL_STYLE } from "@/lib/chart-theme";

interface Props {
summary: DeploymentHistorySummary | null;
loading: boolean;
days: number;
onDaysChange: (days: number) => void;
onFilterChange: (filter: DeploymentHistoryFilter | "all") => void;
onProjectChange: (id: string) => void;
projects: Array<{ id: string; name: string }>;
onRefresh: () => void;
}

export function DeploymentOverview({
summary,
loading,
days,
onDaysChange,
onFilterChange,
onProjectChange,
projects,
onRefresh,
}: Props) {
const { t, locale } = useI18n();
const c = t.deployments.monitoring;
const data = loading ? null : summary;
const completed = data ? data.success + data.failed : 0;
const number = (value: number | undefined) =>
value === undefined ? "—" : value.toLocaleString(locale);
const series = [
{ key: "success", label: t.deployments.stats.success, color: "var(--color-success-solid)" },
{ key: "failed", label: t.deployments.stats.failed, color: "var(--color-danger-solid)" },
{ key: "active", label: t.deployments.stats.inProgress, color: "var(--color-warning-solid)" },
{
key: "canceled",
label: t.deployments.filters.canceled,
color: "var(--color-muted-foreground)",
},
];
const metrics = [
{ label: c.attempts, value: number(data?.total), filter: "all", detail: c.window },
{
label: c.successRate,
value: completed ? `${Math.round((data!.success / completed) * 100)}%` : "—",
filter: "success",
detail: c.rateHint,
},
{
label: t.deployments.stats.failed,
value: number(data?.failed),
filter: "failed",
detail: c.failedHint,
},
{
label: t.deployments.stats.inProgress,
value: number(data?.active),
filter: "active",
detail: c.activeHint,
},
] as const;
return (
<section aria-label={c.title} className="mb-6 space-y-4" aria-busy={loading}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-base font-medium text-foreground">{c.title}</h2>
<p className="mt-1 text-xs text-muted-foreground">{c.scope}</p>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-xl bg-muted/60 p-1" role="group" aria-label={c.period}>
{[7, 30, 90].map((value) => (
<button
key={value}
type="button"
aria-pressed={days === value}
onClick={() => onDaysChange(value)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium focus-visible:ring-2 focus-visible:ring-ring ${days === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
>
{interpolate(c.days, { count: String(value) })}
</button>
))}
</div>
<Button variant="secondary" size="sm" disabled={loading} onClick={onRefresh}>
{c.refresh}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{metrics.map((metric) => (
<button
key={metric.filter}
type="button"
disabled={!data}
onClick={() => onFilterChange(metric.filter)}
className="flex flex-col items-start rounded-2xl bg-card p-5 text-start transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default"
>
<span className="text-xs text-muted-foreground">{metric.label}</span>
<span className="mt-2 block text-3xl font-medium tabular-nums text-foreground">
{metric.value}
</span>
<span className="mt-2 block text-xs text-muted-foreground">{metric.detail}</span>
</button>
))}
</div>
{!loading && !summary ? (
<p role="status" className="rounded-2xl bg-card p-5 text-sm text-muted-foreground">
{c.unavailable}
</p>
) : (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 rounded-2xl bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-medium">{c.trend}</h3>
<span className="text-xs text-muted-foreground">
{c.avgBuild}:{" "}
{data?.avgBuildDurationMs == null
? "—"
: `${Math.round(data.avgBuildDurationMs / 1000).toLocaleString(locale)}s`}
</span>
</div>
<div className="mt-4 h-48" aria-label={c.trend}>
{data && data.total > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.daily} accessibilityLayer>
<CartesianGrid
vertical={false}
stroke="var(--color-border)"
strokeOpacity={0.4}
/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
minTickGap={28}
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
tickFormatter={(date) =>
new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, {
month: "short",
day: "numeric",
timeZone: "UTC",
})
}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={30}
tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }}
/>
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
labelStyle={CHART_TOOLTIP_LABEL_STYLE}
cursor={{ fill: "var(--color-muted)", opacity: 0.3 }}
/>
{series.map((item) => (
<Bar
key={item.key}
dataKey={item.key}
name={item.label}
stackId="outcomes"
fill={item.color}
maxBarSize={24}
/>
))}
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{loading ? t.deployments.header.loading : c.empty}
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
{series.map((item) => (
<span key={item.key} className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ background: item.color }} />
{item.label}
</span>
))}
</div>
</div>
<div className="rounded-2xl bg-card p-5">
<h3 className="text-sm font-medium">{c.projects}</h3>
<p className="mt-1 text-xs text-muted-foreground">{c.projectHint}</p>
<div className="mt-3 space-y-1">
{data?.projects.slice(0, 6).map((project) => (
<button
key={project.projectId}
type="button"
onClick={() => onProjectChange(project.projectId)}
className="flex w-full items-center justify-between gap-3 rounded-xl px-2 py-2.5 text-start hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="truncate text-sm">
{projects.find((p) => p.id === project.projectId)?.name ??
t.deployments.card.unknownProject}
</span>
<span
className={`shrink-0 text-xs tabular-nums ${project.failed ? "text-danger" : "text-muted-foreground"}`}
>
{project.failed}/{project.total} {c.failedShort}
</span>
</button>
))}
{data?.projects.length === 0 && (
<p className="py-4 text-sm text-muted-foreground">{c.empty}</p>
)}
</div>
</div>
</div>
)}
</section>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import type { DeploymentHistorySummary } from "@repo/core";
import { DeploymentOverview } from "./DeploymentOverview";
import { Icon as UiIcon } from "@repo/ui/icons";

import React, { useState, useEffect, useMemo, useCallback } from "react";
Expand All @@ -19,6 +21,7 @@ import {
} from "../utils";

interface DeploymentsContentProps {
monitoring?: boolean;
/** When set, scope to this project and hide the project selector */
projectId?: string;
projectName?: string;
Expand All @@ -36,6 +39,7 @@ export const DeploymentsContent: React.FC<DeploymentsContentProps> = (props) =>
const PAGE_SIZE = 20;

const DeploymentHistory: React.FC<DeploymentsContentProps> = ({
monitoring = false,
projectId,
projectName,
hideHeader = false,
Expand All @@ -48,11 +52,12 @@ const DeploymentHistory: React.FC<DeploymentsContentProps> = ({
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [summary, setSummary] = useState<DeploymentHistorySummary | null>(null);
const [total, setTotal] = useState(0);
const [error, setError] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const [query, setQuery] = useDeploymentHistoryQuery(isProject);
const { page, filter, searchQuery, selectedProjectId } = query;
const { page, days, filter, searchQuery, selectedProjectId } = query;
const refreshDeployments = useCallback(() => setRevision((value) => value + 1), []);

useEffect(() => {
Expand All @@ -62,6 +67,7 @@ const DeploymentHistory: React.FC<DeploymentsContentProps> = ({
const params = {
page,
perPage: PAGE_SIZE,
...(monitoring ? { days } : {}),
status: filter === "all" ? undefined : filter,
search: searchQuery.trim() || undefined,
};
Expand All @@ -82,6 +88,7 @@ const DeploymentHistory: React.FC<DeploymentsContentProps> = ({
}));
setDeployments(mapped);
setTotal(res.total);
setSummary(res.summary ?? null);
if (!isProject) {
setProjects((previous) => {
if (res.projects) return res.projects;
Expand All @@ -97,12 +104,12 @@ const DeploymentHistory: React.FC<DeploymentsContentProps> = ({
});
}
}).catch((err) => {
if (!controller.signal.aborted) setError(getApiErrorMessage(err, t.deployments.loadFailed));
if (!controller.signal.aborted) { setError(getApiErrorMessage(err, t.deployments.loadFailed)); setSummary(null); }
}).finally(() => {
if (!controller.signal.aborted) setIsLoading(false);
});
return () => controller.abort();
}, [projectId, projectName, isProject, page, filter, searchQuery, selectedProjectId, revision, t.deployments.loadFailed, setQuery]);
}, [monitoring, days, projectId, projectName, isProject, page, filter, searchQuery, selectedProjectId, revision, t.deployments.loadFailed, setQuery]);

const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));

Expand All @@ -111,8 +118,28 @@ const DeploymentHistory: React.FC<DeploymentsContentProps> = ({
const activeCount = (stats.building || 0) + (stats.pending || 0);
const failedCount = (stats.failed || 0) + (stats.canceled || 0);

const filters = (
<DeploymentsFilters
isProject={isProject}
filter={filter}
searchQuery={searchQuery}
selectedProjectId={selectedProjectId}
projects={projects}
onFilterChange={(value) => setQuery((previous) => ({ ...previous, filter: value, page: 1 }))}
onSearchChange={(value) => setQuery((previous) => ({ ...previous, searchQuery: value, page: 1 }))}
onProjectChange={(value) => setQuery((previous) => ({ ...previous, selectedProjectId: value, page: 1 }))}
/>
);

return (
<div>
{monitoring && <div className="mb-5">{filters}</div>}
{monitoring && <DeploymentOverview summary={summary} loading={isLoading} days={days} projects={projects}
onDaysChange={days => setQuery(previous => ({ ...previous, days, page: 1 }))}
onFilterChange={filter => setQuery(previous => ({ ...previous, filter, page: 1 }))}
onProjectChange={selectedProjectId => setQuery(previous => ({ ...previous, selectedProjectId, filter: "all", page: 1 }))}
onRefresh={refreshDeployments} />}
{monitoring && <h2 className="mb-3 text-base font-medium">{t.deployments.monitoring.history}</h2>}
{/* Header */}
{!hideHeader && (
<div className="mb-6">
Expand All @@ -134,16 +161,7 @@ const DeploymentHistory: React.FC<DeploymentsContentProps> = ({
<div className={hideSidebar ? "" : "grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-6"}>
{/* LEFT COLUMN */}
<div className="space-y-4 min-w-0">
<DeploymentsFilters
isProject={isProject}
filter={filter}
searchQuery={searchQuery}
selectedProjectId={selectedProjectId}
projects={projects}
onFilterChange={(value) => setQuery((previous) => ({ ...previous, filter: value, page: 1 }))}
onSearchChange={(value) => setQuery((previous) => ({ ...previous, searchQuery: value, page: 1 }))}
onProjectChange={(value) => setQuery((previous) => ({ ...previous, selectedProjectId: value, page: 1 }))}
/>
{!monitoring && filters}
{isLoading ? (
<LoadingSkeleton />
) : error ? (
Expand Down
Loading
Loading