diff --git a/apps/web/app/components/ui/chart.tsx b/apps/web/app/components/ui/chart.tsx index d791ce741..e0aa25224 100644 --- a/apps/web/app/components/ui/chart.tsx +++ b/apps/web/app/components/ui/chart.tsx @@ -1,37 +1,42 @@ -"use client" +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable @typescript-eslint/consistent-indexed-object-style */ +"use client"; -import * as React from "react" -import * as RechartsPrimitive from "recharts" +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; -import { cn } from "~/lib/utils" +import { cn } from "~/lib/utils"; // Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: ".dark" } as const +const THEMES = { light: "", dark: ".dark" } as const; export type ChartConfig = { [k in string]: { - label?: React.ReactNode - icon?: React.ComponentType + label?: React.ReactNode; + icon?: React.ComponentType; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record } - ) -} + ); +}; type ChartContextProps = { - config: ChartConfig -} + config: ChartConfig; +}; -const ChartContext = React.createContext(null) +const ChartContext = React.createContext(null); function useChart() { - const context = React.useContext(ChartContext) + const context = React.useContext(ChartContext); if (!context) { - throw new Error("useChart must be used within a ") + throw new Error("useChart must be used within a "); } - return context + return context; } function ChartContainer({ @@ -41,13 +46,13 @@ function ChartContainer({ config, ...props }: React.ComponentProps<"div"> & { - config: ChartConfig + config: ChartConfig; children: React.ComponentProps< typeof RechartsPrimitive.ResponsiveContainer - >["children"] + >["children"]; }) { - const uniqueId = React.useId() - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; return ( @@ -55,8 +60,8 @@ function ChartContainer({ data-slot="chart" data-chart={chartId} className={cn( - "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", - className + "[&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent", + className, )} {...props} > @@ -66,16 +71,16 @@ function ChartContainer({ - ) + ); } const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( - ([, config]) => config.theme || config.color - ) + ([, config]) => config.theme || config.color, + ); if (!colorConfig.length) { - return null + return null; } return ( @@ -89,20 +94,20 @@ ${colorConfig .map(([key, itemConfig]) => { const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color - return color ? ` --color-${key}: ${color};` : null + itemConfig.color; + return color ? ` --color-${key}: ${color};` : null; }) .join("\n")} } -` +`, ) .join("\n"), }} /> - ) -} + ); +}; -const ChartTooltip = RechartsPrimitive.Tooltip +const ChartTooltip = RechartsPrimitive.Tooltip; function ChartTooltipContent({ active, @@ -120,40 +125,40 @@ function ChartTooltipContent({ labelKey, }: React.ComponentProps & React.ComponentProps<"div"> & { - hideLabel?: boolean - hideIndicator?: boolean - indicator?: "line" | "dot" | "dashed" - nameKey?: string - labelKey?: string + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; }) { - const { config } = useChart() + const { config } = useChart(); const tooltipLabel = React.useMemo(() => { if (hideLabel || !payload?.length) { - return null + return null; } - const [item] = payload - const key = `${labelKey || item?.dataKey || item?.name || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) + const [item] = payload; + const key = `${labelKey || item?.dataKey || item?.name || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); const value = !labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label - : itemConfig?.label + : itemConfig?.label; if (labelFormatter) { return (
{labelFormatter(value, payload)}
- ) + ); } if (!value) { - return null + return null; } - return
{value}
+ return
{value}
; }, [ label, labelFormatter, @@ -162,19 +167,19 @@ function ChartTooltipContent({ labelClassName, config, labelKey, - ]) + ]); if (!active || !payload?.length) { - return null + return null; } - const nestLabel = payload.length === 1 && indicator !== "dot" + const nestLabel = payload.length === 1 && indicator !== "dot"; return (
{!nestLabel ? tooltipLabel : null} @@ -182,16 +187,16 @@ function ChartTooltipContent({ {payload .filter((item) => item.type !== "none") .map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) - const indicatorColor = color || item.payload.fill || item.color + const key = `${nameKey || item.name || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload.fill || item.color; return (
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", - indicator === "dot" && "items-center" + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center", )} > {formatter && item?.value !== undefined && item.name ? ( @@ -204,14 +209,14 @@ function ChartTooltipContent({ !hideIndicator && (
@@ -235,7 +240,7 @@ function ChartTooltipContent({
{item.value && ( - + {item.value.toLocaleString()} )} @@ -243,14 +248,14 @@ function ChartTooltipContent({ )}
- ) + ); })}
- ) + ); } -const ChartLegend = RechartsPrimitive.Legend +const ChartLegend = RechartsPrimitive.Legend; function ChartLegendContent({ className, @@ -260,13 +265,13 @@ function ChartLegendContent({ nameKey, }: React.ComponentProps<"div"> & Pick & { - hideIcon?: boolean - nameKey?: string + hideIcon?: boolean; + nameKey?: string; }) { - const { config } = useChart() + const { config } = useChart(); if (!payload?.length) { - return null + return null; } return ( @@ -274,20 +279,20 @@ function ChartLegendContent({ className={cn( "flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", - className + className, )} > {payload .filter((item) => item.type !== "none") .map((item) => { - const key = `${nameKey || item.dataKey || "value"}` - const itemConfig = getPayloadConfigFromPayload(config, item, key) + const key = `${nameKey || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); return (
svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3" + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", )} > {itemConfig?.icon && !hideIcon ? ( @@ -302,20 +307,20 @@ function ChartLegendContent({ )} {itemConfig?.label}
- ) + ); })} - ) + ); } // Helper to extract item config from a payload. function getPayloadConfigFromPayload( config: ChartConfig, payload: unknown, - key: string + key: string, ) { if (typeof payload !== "object" || payload === null) { - return undefined + return undefined; } const payloadPayload = @@ -323,15 +328,15 @@ function getPayloadConfigFromPayload( typeof payload.payload === "object" && payload.payload !== null ? payload.payload - : undefined + : undefined; - let configLabelKey: string = key + let configLabelKey: string = key; if ( key in payload && typeof payload[key as keyof typeof payload] === "string" ) { - configLabelKey = payload[key as keyof typeof payload] as string + configLabelKey = payload[key as keyof typeof payload] as string; } else if ( payloadPayload && key in payloadPayload && @@ -339,12 +344,12 @@ function getPayloadConfigFromPayload( ) { configLabelKey = payloadPayload[ key as keyof typeof payloadPayload - ] as string + ] as string; } return configLabelKey in config ? config[configLabelKey] - : config[key as keyof typeof config] + : config[key as keyof typeof config]; } export { @@ -354,4 +359,4 @@ export { ChartLegend, ChartLegendContent, ChartStyle, -} +}; diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 8b58c1e5b..c688b76f5 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -99,6 +99,7 @@ export default [ route("general", "routes/ws/settings/general.tsx"), route("members", "routes/ws/settings/members.tsx"), route("api-keys", "routes/ws/settings/api-keys.tsx"), + route("work-queue", "routes/ws/settings/work-queue.tsx"), route("delete-workspace", "routes/ws/settings/delete-workspace.tsx"), ]), ]), diff --git a/apps/web/app/routes/ws/settings/_components/work-queue/CreateWorkItemDialog.tsx b/apps/web/app/routes/ws/settings/_components/work-queue/CreateWorkItemDialog.tsx new file mode 100644 index 000000000..40f5c82ee --- /dev/null +++ b/apps/web/app/routes/ws/settings/_components/work-queue/CreateWorkItemDialog.tsx @@ -0,0 +1,260 @@ +import type React from "react"; +import { useState } from "react"; +import { PlusIcon } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Textarea } from "~/components/ui/textarea"; + +import { trpc } from "~/api/trpc"; + +const WORK_ITEM_KINDS = [ + { value: "desired-release", label: "Desired Release" }, + { value: "environment-resource-selector-eval", label: "Environment Resource Selector Eval" }, + { value: "deployment-resource-selector-eval", label: "Deployment Resource Selector Eval" }, + { value: "relationship-eval", label: "Relationship Eval" }, +] as const; + +const defaultForm = { + kind: "", + scopeType: "", + scopeId: "", + priority: "100", + notBefore: "", + includePayload: false, + payloadType: "", + payloadKey: "", + payloadJson: "{}", +}; + +export const CreateWorkItemDialog: React.FC<{ + workspaceId: string; +}> = ({ workspaceId }) => { + const [open, setOpen] = useState(false); + const [form, setForm] = useState(defaultForm); + const [jsonError, setJsonError] = useState(null); + + const utils = trpc.useUtils(); + const createMutation = trpc.reconcile.create.useMutation({ + onSuccess: () => { + utils.reconcile.listWorkScopes.invalidate(); + utils.reconcile.stats.invalidate(); + utils.reconcile.chartData.invalidate(); + setOpen(false); + setForm(defaultForm); + setJsonError(null); + }, + }); + + const update = (field: keyof typeof form, value: string | boolean) => + setForm((f) => ({ ...f, [field]: value })); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + let parsedPayload: Record = {}; + if (form.includePayload) { + try { + parsedPayload = JSON.parse(form.payloadJson) as Record< + string, + unknown + >; + setJsonError(null); + } catch { + setJsonError("Invalid JSON"); + return; + } + } + + createMutation.mutate({ + workspaceId, + kind: form.kind, + scopeType: form.scopeType, + scopeId: form.scopeId, + priority: Number(form.priority), + notBefore: form.notBefore ? new Date(form.notBefore) : undefined, + payload: form.includePayload + ? { + payloadType: form.payloadType, + payloadKey: form.payloadKey, + payload: parsedPayload, + } + : undefined, + }); + }; + + return ( + + + + + + + Create Work Item + + Manually enqueue a reconcile work scope and optional payload. + + + +
+
+ + +
+ +
+
+ + update("scopeType", e.target.value)} + /> +
+
+ + update("scopeId", e.target.value)} + /> +
+
+ + update("priority", e.target.value)} + /> +
+
+ +
+ + update("notBefore", e.target.value)} + /> +

+ Leave empty for immediate processing. +

+
+ +
+ update("includePayload", e.target.checked)} + /> + +
+ + {form.includePayload && ( +
+
+
+ + update("payloadType", e.target.value)} + /> +
+
+ + update("payloadKey", e.target.value)} + /> +
+
+
+ +