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
2 changes: 1 addition & 1 deletion apps/ctrlshell/src/payloads/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { z } from "zod";
import type { z } from "zod";

import agentConnect from "./agent-connect";
import agentHeartbeat from "./agent-heartbeat";
Expand Down
222 changes: 174 additions & 48 deletions apps/webservice/src/app/[workspaceSlug]/systems/JobHistoryChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@

import type { Workspace } from "@ctrlplane/db/schema";
import type { JobCondition } from "@ctrlplane/validators/jobs";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { addDays, isSameDay, startOfDay, sub } from "date-fns";
import _ from "lodash";
import * as LZString from "lz-string";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import {
Bar,
CartesianGrid,
ComposedChart,
Line,
XAxis,
YAxis,
} from "recharts";
import colors from "tailwindcss/colors";

import {
Expand All @@ -16,6 +24,7 @@ import {
CardTitle,
} from "@ctrlplane/ui/card";
import { ChartContainer, ChartTooltip } from "@ctrlplane/ui/chart";
import { Checkbox } from "@ctrlplane/ui/checkbox";
import {
ComparisonOperator,
DateOperator,
Expand All @@ -31,7 +40,7 @@ const statusColors = {
[JobStatus.ExternalRunNotFound]: colors.red[700],
[JobStatus.InvalidIntegration]: colors.amber[700],
[JobStatus.InvalidJobAgent]: colors.amber[400],
[JobStatus.Failure]: colors.red[500],
[JobStatus.Failure]: colors.red[600],
[JobStatus.InProgress]: colors.blue[500],
[JobStatus.Completed]: colors.green[500],
};
Expand Down Expand Up @@ -60,12 +69,24 @@ export const JobHistoryChart: React.FC<{
const chartData = dateRange(sub(now, { weeks: 6 }), now, 1, "days").map(
(d) => {
const dayData =
dailyCounts.data?.find((c) => isSameDay(c.date, d))?.statusCounts ?? {};
dailyCounts.data?.find((c) => isSameDay(c.date, d))?.statusCounts ??
({} as Record<JobStatus, number | undefined>);
const total = _.sumBy(Object.values(dayData), (c) => c ?? 0);
const failureCount = dayData[JobStatus.Failure] ?? 0;
const failureRate = total > 0 ? (failureCount / total) * 100 : 0;
const date = new Date(d).toISOString();
return { date, ...dayData };
return { date, ...dayData, failureRate };
},
);

const maxFailureRate = _.maxBy(chartData, "failureRate")?.failureRate ?? 0;
const maxLineTickDomain =
maxFailureRate > 0 ? Math.min(100, Math.ceil(maxFailureRate * 1.1)) : 10;

const maxDailyCount =
_.maxBy(dailyCounts.data, "totalCount")?.totalCount ?? 0;
const maxBarTickDomain = Math.ceil(maxDailyCount * 1.1);

const targets = api.target.byWorkspaceId.list.useQuery({
workspaceId: workspace.id,
limit: 0,
Expand All @@ -77,8 +98,28 @@ export const JobHistoryChart: React.FC<{
0,
);

const [showFailureRate, setShowFailureRate] = useState(false);
const [showTooltip, setShowTooltip] = useState(true);

const router = useRouter();

/*
Hack - if animation is active, the bars animate on every render, including when
we toggle the tooltip or failure rate. Hence we should set a timeout with a duration
little longer than the animation duration and then disable the animation on
subsequent renders.
*/
const [isAnimationActive, setIsAnimationActive] = useState(true);
const animationDuration = 800;

useEffect(() => {
const timeout = setTimeout(
() => setIsAnimationActive(false),
animationDuration + 100,
);
return () => clearTimeout(timeout);
}, []);

return (
<div className={className}>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
Expand Down Expand Up @@ -111,15 +152,15 @@ export const JobHistoryChart: React.FC<{
</div>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<CardContent className="flex px-2 sm:p-6">
<ChartContainer
config={{
views: { label: "Job Executions" },
jobs: { label: "Executions", color: "hsl(var(--chart-1))" },
}}
className="aspect-auto h-[150px] w-full"
>
<BarChart
<ComposedChart
accessibilityLayer
data={chartData}
margin={{
Expand All @@ -142,54 +183,100 @@ export const JobHistoryChart: React.FC<{
});
}}
/>
<ChartTooltip
content={({ active, payload, label }) => {
if (active && payload?.length)
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="font-semibold">
{new Date(label).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</div>
{payload.reverse().map((entry, index) => (
<div
key={`item-${index}`}
className="flex items-center gap-2"
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span>
{
statusLabels[
entry.name as Exclude<
JobStatus,
| JobStatus.Cancelled
| JobStatus.Skipped
| JobStatus.Pending
>
]
}
:{" "}
</span>
<span className="font-semibold">{entry.value}</span>
</div>
))}
</div>
);
return null;
}}

{showFailureRate && (
<YAxis
yAxisId="left"
orientation="left"
tickFormatter={(value: number) => `${value.toFixed(1)}%`}
domain={[0, maxLineTickDomain]}
/>
)}
<YAxis
yAxisId="right"
orientation="right"
tickFormatter={(value: number) => `${value}`}
domain={[0, maxBarTickDomain]}
/>

{showTooltip && (
<ChartTooltip
content={({ active, payload, label }) => {
const total = _.sumBy(
payload?.filter((p) => p.name !== "failureRate"),
(p) => Number(p.value ?? 0),
);
const failureRate = Math.round(
Number(
payload?.find((p) => p.name === "failureRate")?.value ??
0,
),
);
if (active && payload?.length)
return (
<div className="space-y-2 rounded-lg border bg-background p-2 shadow-sm">
<div className="font-semibold">
{new Date(label).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</div>

<div className="flex flex-col">
<span>Total: {total}</span>
<span>Failure Rate: {failureRate}%</span>
</div>

<div>
{payload
.filter((p) => p.name !== "failureRate")
.reverse()
.map((entry, index) => (
<div
key={`item-${index}`}
className="flex items-center gap-2"
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span>
{
statusLabels[
entry.name as Exclude<
JobStatus,
| JobStatus.Cancelled
| JobStatus.Skipped
| JobStatus.Pending
>
]
}
:{" "}
</span>
<span className="font-semibold">
{entry.value}
</span>
</div>
))}
</div>
</div>
);
return null;
}}
/>
)}

{Object.entries(statusColors).map(([status, color]) => (
<Bar
key={status}
dataKey={status.toLowerCase()}
stackId="jobs"
className="cursor-pointer"
yAxisId="right"
isAnimationActive={isAnimationActive}
animationBegin={0}
animationDuration={animationDuration}
fill={color}
onClick={(e) => {
const start = new Date(e.date);
Expand Down Expand Up @@ -221,8 +308,47 @@ export const JobHistoryChart: React.FC<{
}}
/>
))}
</BarChart>
{showFailureRate && (
<Line
yAxisId="left"
dataKey="failureRate"
stroke={colors.red[600]}
strokeWidth={2}
opacity={0.5}
dot={false}
/>
)}
</ComposedChart>
</ChartContainer>

<div className="flex flex-shrink-0 flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="show-tooltip"
checked={showTooltip}
onCheckedChange={(checked) => setShowTooltip(!!checked)}
/>
<label
htmlFor="show-tooltip"
className="text-sm text-muted-foreground"
>
Show tooltip
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="show-failure-rate"
checked={showFailureRate}
onCheckedChange={(checked) => setShowFailureRate(!!checked)}
/>
<label
htmlFor="show-failure-rate"
className="text-sm text-muted-foreground"
>
Show failure rate
</label>
</div>
</div>
</CardContent>
</div>
);
Expand Down
Loading