diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(job)/jobs/JobTable.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(job)/jobs/JobTable.tsx index ae9771582..b0d5f2db8 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(job)/jobs/JobTable.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(job)/jobs/JobTable.tsx @@ -34,7 +34,7 @@ export const JobTable: React.FC = ({ workspaceId }) => { const { filter, setFilter } = useFilter(); const { setJobId } = useJobDrawer(); const allReleaseJobTriggers = api.job.config.byWorkspaceId.list.useQuery( - { workspaceId }, + { workspaceId, limit: 0 }, { refetchInterval: 60_000, placeholderData: (prev) => prev }, ); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobComparisonConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobComparisonConditionRender.tsx new file mode 100644 index 000000000..c3ede603b --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobComparisonConditionRender.tsx @@ -0,0 +1,348 @@ +import type { + ComparisonCondition, + JobCondition, +} from "@ctrlplane/validators/jobs"; +import type React from "react"; +import { + IconChevronDown, + IconCopy, + IconDots, + IconEqualNot, + IconPlus, + IconRefresh, + IconTrash, +} from "@tabler/icons-react"; +import { capitalCase } from "change-case"; + +import { cn } from "@ctrlplane/ui"; +import { Button } from "@ctrlplane/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ctrlplane/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; +import { + ColumnOperator, + ComparisonOperator, + DateOperator, + FilterType, + MetadataOperator, +} from "@ctrlplane/validators/conditions"; +import { + doesConvertingToComparisonRespectMaxDepth, + isComparisonCondition, + JobFilterType, + JobStatus, +} from "@ctrlplane/validators/jobs"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { JobConditionRender } from "./JobConditionRender"; + +export const RunbookJobComparisonConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, depth = 0, className }) => { + const setOperator = ( + operator: ComparisonOperator.And | ComparisonOperator.Or, + ) => onChange({ ...condition, operator }); + + const updateCondition = (index: number, changedCondition: JobCondition) => + onChange({ + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? changedCondition : c, + ), + }); + + const addCondition = (changedCondition: JobCondition) => + onChange({ + ...condition, + conditions: [...condition.conditions, changedCondition], + }); + + const removeCondition = (index: number) => + onChange({ + ...condition, + conditions: condition.conditions.filter((_, i) => i !== index), + }); + + const convertToComparison = (index: number) => { + const cond = condition.conditions[index]; + if (!cond) return; + + if (!doesConvertingToComparisonRespectMaxDepth(depth + 1, cond)) return; + + const newComparisonCondition: ComparisonCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + conditions: [cond], + }; + + const newCondition = { + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? newComparisonCondition : c, + ), + }; + onChange(newCondition); + }; + + const convertToNotComparison = (index: number) => { + const cond = condition.conditions[index]; + if (!cond) return; + + if (isComparisonCondition(cond)) { + const currentNot = cond.not ?? false; + const newNotSubcondition = { + ...cond, + not: !currentNot, + }; + const newCondition = { + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? newNotSubcondition : c, + ), + }; + onChange(newCondition); + return; + } + + const newNotComparisonCondition: ComparisonCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: true, + conditions: [cond], + }; + + const newCondition = { + ...condition, + conditions: condition.conditions.map((c, i) => + i === index ? newNotComparisonCondition : c, + ), + }; + onChange(newCondition); + }; + + const clear = () => onChange({ ...condition, conditions: [] }); + + const not = condition.not ?? false; + + return ( +
+ {condition.conditions.length === 0 && ( + + {not ? "Empty not group" : "No conditions"} + + )} +
+ {condition.conditions.map((subCond, index) => ( +
+
+ {index !== 1 && ( +
+ {index !== 0 && capitalCase(condition.operator)} + {index === 0 && !condition.not && "When"} + {index === 0 && condition.not && "Not"} +
+ )} + {index === 1 && ( + + )} + updateCondition(index, c)} + depth={depth + 1} + className={cn(depth === 0 ? "col-span-11" : "col-span-10")} + /> +
+ + + + + + + removeCondition(index)} + className="flex items-center gap-2" + > + + Remove + + addCondition(subCond)} + className="flex items-center gap-2" + > + + Duplicate + + {doesConvertingToComparisonRespectMaxDepth( + depth + 1, + subCond, + ) && ( + convertToComparison(index)} + className="flex items-center gap-2" + > + + Turn into group + + )} + {(isComparisonCondition(subCond) || + doesConvertingToComparisonRespectMaxDepth( + depth + 1, + subCond, + )) && ( + convertToNotComparison(index)} + className="flex items-center gap-2" + > + + Negate condition + + )} + + +
+ ))} +
+ +
+ + + + + + + + addCondition({ + type: FilterType.Metadata, + operator: MetadataOperator.Equals, + key: "", + value: "", + }) + } + > + Metadata + + + addCondition({ + type: FilterType.CreatedAt, + operator: DateOperator.Before, + value: new Date().toISOString(), + }) + } + > + Created at + + + addCondition({ + type: JobFilterType.Status, + operator: ColumnOperator.Equals, + value: JobStatus.Completed, + }) + } + > + Status + + {depth < 2 && ( + + addCondition({ + type: FilterType.Comparison, + operator: ComparisonOperator.And, + conditions: [], + not: false, + }) + } + > + Filter group + + )} + {depth < 2 && ( + + addCondition({ + type: FilterType.Comparison, + operator: ComparisonOperator.And, + not: true, + conditions: [], + }) + } + > + Not group + + )} + + + +
+ +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobConditionDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobConditionDialog.tsx new file mode 100644 index 000000000..90aa0daea --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobConditionDialog.tsx @@ -0,0 +1,90 @@ +import type { JobCondition } from "@ctrlplane/validators/jobs"; +import { useState } from "react"; + +import { Button } from "@ctrlplane/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { MAX_DEPTH_ALLOWED } from "@ctrlplane/validators/conditions"; +import { + defaultCondition, + isEmptyCondition, + isValidJobCondition, +} from "@ctrlplane/validators/jobs"; + +import { RunbookJobConditionRender } from "./RunbookJobConditionRender"; + +type RunbookJobConditionDialogProps = { + condition: JobCondition | null; + onChange: (condition: JobCondition | null) => void; + children: React.ReactNode; +}; + +export const RunbookJobConditionDialog: React.FC< + RunbookJobConditionDialogProps +> = ({ condition, onChange, children }) => { + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const [localCondition, setLocalCondition] = useState( + condition ?? defaultCondition, + ); + + return ( + + {children} + e.stopPropagation()} + > + + Edit Job Condition + + Edit the job filter, up to a depth of {MAX_DEPTH_ALLOWED + 1}. + + + + {error && {error}} + + +
+ + + +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobConditionRender.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobConditionRender.tsx new file mode 100644 index 000000000..91807393b --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobConditionRender.tsx @@ -0,0 +1,64 @@ +import type { JobCondition } from "@ctrlplane/validators/jobs"; +import React from "react"; + +import { + isComparisonCondition, + isCreatedAtCondition, + isMetadataCondition, + isStatusCondition, +} from "@ctrlplane/validators/jobs"; + +import type { JobConditionRenderProps } from "./job-condition-props"; +import { JobCreatedAtConditionRender } from "./JobCreatedAtConditionRender"; +import { JobMetadataConditionRender } from "./JobMetadataConditionRender"; +import { RunbookJobComparisonConditionRender } from "./RunbookJobComparisonConditionRender"; +import { StatusConditionRender } from "./StatusConditionRender"; + +/** + * The parent container should have min width of 1000px + * to render this component properly. + */ +export const RunbookJobConditionRender: React.FC< + JobConditionRenderProps +> = ({ condition, onChange, depth = 0, className }) => { + if (isComparisonCondition(condition)) + return ( + + ); + + if (isCreatedAtCondition(condition)) + return ( + + ); + + if (isMetadataCondition(condition)) + return ( + + ); + + if (isStatusCondition(condition)) + return ( + + ); + + return null; +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/SystemsBreadcrumb.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/SystemsBreadcrumb.tsx index 74da12297..9e721a954 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/SystemsBreadcrumb.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/SystemsBreadcrumb.tsx @@ -1,5 +1,6 @@ import { Fragment } from "react"; import { + IconBook, IconCategory, IconDotsVertical, IconServer, @@ -28,14 +29,18 @@ export const SystemBreadcrumbNavbar = async ({ systemSlug?: string; deploymentSlug?: string; versionId?: string; + runbookId?: string; }; }) => { - const { workspaceSlug, systemSlug, deploymentSlug, versionId } = params; + const { workspaceSlug, systemSlug, deploymentSlug, versionId, runbookId } = + params; const system = systemSlug ? await api.system.bySlug({ workspaceSlug, systemSlug }) : null; + const runbook = runbookId ? await api.runbook.byId(runbookId) : null; + const deployment = deploymentSlug && systemSlug ? await api.deployment.bySlug({ @@ -84,6 +89,15 @@ export const SystemBreadcrumbNavbar = async ({ ), path: `/${workspaceSlug}/systems/${systemSlug}/releases/${versionId}`, }, + { + isSet: runbook != null, + name: ( + <> + {runbook?.name} + + ), + path: `/${workspaceSlug}/systems/${systemSlug}/runbooks/${runbookId}`, + }, ].filter((t) => t.isSet); return ( diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/_components/nFormatter.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/_components/nFormatter.ts new file mode 100644 index 000000000..2c6af24a2 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/_components/nFormatter.ts @@ -0,0 +1,5 @@ +export const nFormatter = (num: number, digits: number) => + new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: digits, + }).format(num); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentNavBar.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentNavBar.tsx index d8b5664b0..f4de6e7b5 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentNavBar.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/[deploymentSlug]/DeploymentNavBar.tsx @@ -11,6 +11,7 @@ import { NavigationMenuList, } from "@ctrlplane/ui/navigation-menu"; +import { nFormatter } from "~/app/[workspaceSlug]/(app)/systems/[systemSlug]/_components/nFormatter"; import { NavigationMenuAction } from "./NavigationMenuAction"; type DeploymentNavBarProps = { @@ -21,23 +22,6 @@ type DeploymentNavBarProps = { totalReleases: number; }; -const nFormatter = (num: number, digits: number) => { - const lookup = [ - { value: 1, symbol: "" }, - { value: 1e3, symbol: "k" }, - { value: 1e6, symbol: "M" }, - { value: 1e9, symbol: "G" }, - { value: 1e12, symbol: "T" }, - { value: 1e15, symbol: "P" }, - { value: 1e18, symbol: "E" }, - ]; - const regexp = /\.0+$|(?<=\.[0-9]*[1-9])0+$/; - const item = lookup.reverse().find((item) => num >= item.value); - return item - ? (num / item.value).toFixed(digits).replace(regexp, "").concat(item.symbol) - : "0"; -}; - export const DeploymentNavBar: React.FC = ({ deployment, totalReleases, diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/RunbookNavBar.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/RunbookNavBar.tsx new file mode 100644 index 000000000..e01b758d3 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/RunbookNavBar.tsx @@ -0,0 +1,78 @@ +"use client"; + +import type React from "react"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { IconBolt } from "@tabler/icons-react"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { Button } from "@ctrlplane/ui/button"; +import { + NavigationMenu, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, +} from "@ctrlplane/ui/navigation-menu"; + +import { nFormatter } from "../../_components/nFormatter"; + +type RunbookNavBarProps = { + totalJobs: number; +}; + +export const RunbookNavBar: React.FC = ({ totalJobs }) => { + const { workspaceSlug, systemSlug, runbookId } = useParams<{ + workspaceSlug: string; + systemSlug: string; + runbookId: string; + }>(); + + const pathname = usePathname(); + + const baseUrl = `/${workspaceSlug}/systems/${systemSlug}/runbooks/${runbookId}`; + const settingsUrl = `${baseUrl}/settings`; + + const isSettingsActive = pathname.endsWith("/settings"); + const isJobsActive = !isSettingsActive; + + return ( +
+
+ + + + + + Jobs + + {nFormatter(totalJobs, 1)} + + + + + + + + Settings + + + + + +
+ +
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/layout.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/layout.tsx new file mode 100644 index 000000000..5be374148 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/layout.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { api } from "~/trpc/server"; +import { SystemBreadcrumbNavbar } from "../../../SystemsBreadcrumb"; +import { TopNav } from "../../../TopNav"; +import { RunbookNavBar } from "./RunbookNavBar"; + +type PageProps = { + params: { workspaceSlug: string; systemSlug: string; runbookId: string }; +}; + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const runbook = await api.runbook.byId(params.runbookId); + if (runbook == null) return notFound(); + + return { + title: `${runbook.name} | Runbooks`, + }; +} + +export default async function RunbookLayout({ + children, + params, +}: { + children: React.ReactNode; +} & PageProps) { + const workspace = await api.workspace.bySlug(params.workspaceSlug); + if (workspace == null) return notFound(); + + const runbook = await api.runbook.byId(params.runbookId); + if (runbook == null) return notFound(); + + const { runbookId } = params; + const { total } = await api.runbook.jobs({ runbookId, limit: 0 }); + + return ( + <> + +
+ +
+
+ + +
+ {children} +
+ + ); +} diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/page.tsx new file mode 100644 index 000000000..e74f98e83 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/runbooks/[runbookId]/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { JobCondition } from "@ctrlplane/validators/jobs"; +import { IconFilter, IconLoader2 } from "@tabler/icons-react"; +import { formatDistanceToNowStrict } from "date-fns"; +import _ from "lodash"; + +import { Badge } from "@ctrlplane/ui/badge"; +import { Button } from "@ctrlplane/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@ctrlplane/ui/hover-card"; +import { Skeleton } from "@ctrlplane/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@ctrlplane/ui/table"; +import { JobStatusReadable } from "@ctrlplane/validators/jobs"; + +import { NoFilterMatch } from "~/app/[workspaceSlug]/(app)/_components/filter/NoFilterMatch"; +import { JobConditionBadge } from "~/app/[workspaceSlug]/(app)/_components/job-condition/JobConditionBadge"; +import { RunbookJobConditionDialog } from "~/app/[workspaceSlug]/(app)/_components/job-condition/RunbookJobConditionDialog"; +import { JobTableStatusIcon } from "~/app/[workspaceSlug]/(app)/_components/JobTableStatusIcon"; +import { useFilter } from "~/app/[workspaceSlug]/(app)/_components/useFilter"; +import { api } from "~/trpc/react"; + +type VariableCellProps = { + variables: SCHEMA.JobVariable[]; +}; + +const VariableCell: React.FC = ({ variables }) => { + return ( + + {variables.length > 0 && ( + + + + + + {variables.map((v) => ( +
+ {v.key}: {String(v.value)} +
+ ))} +
+
+ )} +
+ ); +}; + +export default function RunbookPage({ + params, +}: { + params: { runbookId: string }; +}) { + const { filter, setFilter } = useFilter(); + const { data: allRunbookJobs } = api.runbook.jobs.useQuery({ + runbookId: params.runbookId, + limit: 0, + }); + + const { runbookId } = params; + const { data: runbookJobs, ...runbookJobsQ } = api.runbook.jobs.useQuery( + { runbookId, filter: filter ?? undefined, limit: 100 }, + { refetchInterval: 10_000, placeholderData: (prev) => prev }, + ); + + return ( +
+
+
+ +
+ + {filter != null && } +
+
+ {!runbookJobsQ.isLoading && runbookJobsQ.isFetching && ( + + )} +
+ + {runbookJobs?.total != null && ( +
+ Total: + + {runbookJobs.total} + +
+ )} +
+ + {runbookJobsQ.isLoading && ( +
+ {_.range(10).map((i) => ( + + ))} +
+ )} + {runbookJobsQ.isSuccess && runbookJobs?.total === 0 && ( + setFilter(null)} + /> + )} + + {runbookJobsQ.isSuccess && + runbookJobs != null && + runbookJobs.items.length > 0 && ( +
+ + + + Status + Created At + Variables + + + + {runbookJobs.items.map((job) => ( + + +
+ + {JobStatusReadable[job.job.status]} +
+
+ + {formatDistanceToNowStrict(job.job.createdAt, { + addSuffix: true, + })} + + +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/packages/api/src/router/runbook.ts b/packages/api/src/router/runbook.ts index f7c3b7719..bdb4906f9 100644 --- a/packages/api/src/router/runbook.ts +++ b/packages/api/src/router/runbook.ts @@ -1,15 +1,29 @@ import _ from "lodash"; +import { isPresent } from "ts-is-present"; import { z } from "zod"; -import { eq, takeFirst } from "@ctrlplane/db"; -import { createRunbook, createRunbookVariable } from "@ctrlplane/db/schema"; +import { and, desc, eq, sql, takeFirst } from "@ctrlplane/db"; import * as SCHEMA from "@ctrlplane/db/schema"; import { dispatchRunbook } from "@ctrlplane/job-dispatch"; import { Permission } from "@ctrlplane/validators/auth"; +import { jobCondition } from "@ctrlplane/validators/jobs"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const runbookRouter = createTRPCRouter({ + byId: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: async ({ canUser, input }) => + canUser.perform(Permission.RunbookList).on({ + type: "runbook", + id: input, + }), + }) + .query(({ ctx, input }) => + ctx.db.query.runbook.findFirst({ where: eq(SCHEMA.runbook.id, input) }), + ), + trigger: protectedProcedure .meta({ authorizationCheck: async ({ canUser, input }) => @@ -53,7 +67,11 @@ export const runbookRouter = createTRPCRouter({ .perform(Permission.RunbookCreate) .on({ type: "system", id: input.systemId }), }) - .input(createRunbook.extend({ variables: z.array(createRunbookVariable) })) + .input( + SCHEMA.createRunbook.extend({ + variables: z.array(SCHEMA.createRunbookVariable), + }), + ) .mutation(async ({ ctx, input }) => ctx.db.transaction(async (tx) => { const { variables, ...rb } = input; @@ -86,7 +104,7 @@ export const runbookRouter = createTRPCRouter({ z.object({ id: z.string().uuid(), data: SCHEMA.updateRunbook.extend({ - variables: z.array(createRunbookVariable).optional(), + variables: z.array(SCHEMA.createRunbookVariable).optional(), }), }), ) @@ -126,4 +144,82 @@ export const runbookRouter = createTRPCRouter({ .mutation(({ ctx, input }) => ctx.db.delete(SCHEMA.runbook).where(eq(SCHEMA.runbook.id, input)), ), + + jobs: protectedProcedure + .input( + z.object({ + runbookId: z.string().uuid(), + filter: jobCondition.optional(), + limit: z.number().default(500), + offset: z.number().default(0), + }), + ) + .meta({ + authorizationCheck: async ({ canUser, input }) => + canUser.perform(Permission.JobList).on({ + type: "runbook", + id: input.runbookId, + }), + }) + .query(async ({ ctx, input }) => { + const items = ctx.db + .select() + .from(SCHEMA.job) + .innerJoin( + SCHEMA.runbookJobTrigger, + eq(SCHEMA.job.id, SCHEMA.runbookJobTrigger.jobId), + ) + .leftJoin( + SCHEMA.jobVariable, + eq(SCHEMA.jobVariable.jobId, SCHEMA.job.id), + ) + .where( + and( + eq(SCHEMA.runbookJobTrigger.runbookId, input.runbookId), + SCHEMA.jobMatchesCondition(ctx.db, input.filter), + ), + ) + .orderBy(desc(SCHEMA.job.createdAt)) + .limit(input.limit) + .offset(input.offset) + .then((rows) => + _.chain(rows) + .groupBy((j) => j.job.id) + .map((j) => ({ + runbookJobTrigger: j[0]!.runbook_job_trigger, + job: { + ...j[0]!.job, + variables: j + .map((v) => v.job_variable) + .filter(isPresent) + .map((v) => ({ + ...v, + value: v.sensitive ? "(sensitive)" : v.value, + })), + }, + })) + .value(), + ); + + const total = ctx.db + .select({ count: sql`COUNT(*)`.mapWith(Number) }) + .from(SCHEMA.job) + .innerJoin( + SCHEMA.runbookJobTrigger, + eq(SCHEMA.job.id, SCHEMA.runbookJobTrigger.jobId), + ) + .where( + and( + eq(SCHEMA.runbookJobTrigger.runbookId, input.runbookId), + SCHEMA.jobMatchesCondition(ctx.db, input.filter), + ), + ) + .then(takeFirst) + .then((t) => t.count); + + return Promise.all([items, total]).then(([items, total]) => ({ + items, + total, + })); + }), });