Metrics dashboards (#3019)

Summary
- Implemented metrics dashboards with a built-in dashboard and custom
dashboards
- Added a "Big number” display type

What changed
- New data format for metric layouts and saving/editing layouts
(editing, saving, cancel revert)
  - QueryWidget usable on Query page and Metrics dashboards
  - Time filtering, auto-reloading and timeBucket() auto-bin support
- Filters added to metrics; widget popover/improved history and blank
states
- Side menu:
- Metrics/Insights section with icons, colors, padding, collapsible
behavior and reordering of custom dashboards
- Move action logic into service for reuse and API querying; refactor
reordering for reuse
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3019"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: James Ritchie <james@trigger.dev>
This commit is contained in:
Matt Aitken
2026-02-12 17:48:02 +00:00
committed by GitHub
parent 062bcaece8
commit bc0d1ff59a
89 changed files with 9408 additions and 1948 deletions
-1
View File
@@ -7,6 +7,5 @@
"packages/cli-v3/e2e": true
},
"vitest.disableWorkspaceWarning": true,
"typescript.experimental.useTsgo": true,
"chat.agent.maxRequests": 10000
}
+29
View File
@@ -30,3 +30,32 @@ export function AlphaTitle({ children }: { children: React.ReactNode }) {
</>
);
}
export function BetaBadge({
inline = false,
className,
}: {
inline?: boolean;
className?: string;
}) {
return (
<SimpleTooltip
button={
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
Beta
</Badge>
}
content="This feature is in Beta."
disableHoverableContent
/>
);
}
export function BetaTitle({ children }: { children: React.ReactNode }) {
return (
<>
<span>{children}</span>
<BetaBadge />
</>
);
}
@@ -1,7 +1,13 @@
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
import { cn } from "~/utils/cn";
// Lazy load streamdown components to avoid SSR issues
const StreamdownRenderer = lazy(() =>
@@ -13,13 +19,6 @@ const StreamdownRenderer = lazy(() =>
),
}))
);
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
import { cn } from "~/utils/cn";
type StreamEventType =
| { type: "thinking"; content: string }
@@ -179,21 +178,7 @@ export function AIQueryInput({
setThinking((prev) => prev + event.content);
break;
case "tool_call":
if (event.tool === "setTimeFilter") {
setThinking((prev) => {
if (prev.trimEnd().endsWith("Setting time filter...")) {
return prev;
}
return prev + `\nSetting time filter...\n`;
});
} else {
setThinking((prev) => {
if (prev.trimEnd().endsWith("Validating query...")) {
return prev;
}
return prev + `\nValidating query...\n`;
});
}
// Tool calls are handled silently — no UI text needed
break;
case "time_filter":
// Apply time filter immediately when the AI sets it
@@ -262,13 +247,13 @@ export function AIQueryInput({
}, [error]);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col">
{/* Gradient border wrapper like the schedules AI input */}
<div
className="rounded-md p-px"
className="overflow-hidden rounded-md p-px"
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
>
<div className="overflow-hidden rounded-[5px] bg-background-bright">
<div className="overflow-hidden rounded-md bg-background-bright">
<form onSubmit={handleSubmit}>
<textarea
ref={textareaRef}
@@ -297,10 +282,10 @@ export function AIQueryInput({
variant="tertiary/small"
disabled={true}
LeadingIcon={Spinner}
className="pl-1.5"
className="pl-2"
iconSpacing="gap-1.5"
>
{mode === "edit" ? "Editing..." : "Generating..."}
{mode === "edit" ? "Editing" : "Generating"}
</Button>
) : (
<>
@@ -366,64 +351,60 @@ export function AIQueryInput({
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{isLoading ? (
<Spinner
color={{
background: "rgba(99, 102, 241, 0.3)",
foreground: "rgba(99, 102, 241, 1)",
}}
className="size-3"
/>
) : lastResult === "success" ? (
<div className="size-3 rounded-full bg-success" />
) : lastResult === "error" ? (
<div className="size-3 rounded-full bg-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking..."
: lastResult === "success"
<div className="px-1">
<div className="rounded-b-lg border-x border-b border-grid-dimmed bg-charcoal-850 p-3 pb-1">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
{isLoading ? (
<Spinner className="size-4" />
) : lastResult === "success" ? (
<CheckIcon className="size-4 text-success" />
) : lastResult === "error" ? (
<XMarkIcon className="size-4 text-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking…"
: lastResult === "success"
? "Query generated"
: lastResult === "error"
? "Generation failed"
: "AI response"}
</span>
? "Generation failed"
: "AI response"}
</span>
</div>
{isLoading ? (
<Button
variant="minimal/small"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Cancel
</Button>
) : (
<Button
variant="minimal/small"
onClick={() => {
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Dismiss
</Button>
)}
</div>
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
{isLoading ? (
<Button
variant="minimal/small"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Cancel
</Button>
) : (
<Button
variant="minimal/small"
onClick={() => {
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Dismiss
</Button>
)}
</div>
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
</div>
</motion.div>
@@ -1,27 +1,18 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { BarChart, LineChart, Plus, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Header3 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { Select, SelectItem } from "../primitives/Select";
import { Switch } from "../primitives/Switch";
import { Button } from "../primitives/Buttons";
export type ChartType = "bar" | "line";
export type SortDirection = "asc" | "desc";
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
export interface ChartConfiguration {
chartType: ChartType;
xAxisColumn: string | null;
yAxisColumns: string[];
groupByColumn: string | null;
stacked: boolean;
sortByColumn: string | null;
sortDirection: SortDirection;
aggregation: AggregationType;
}
import {
type AggregationType,
type ChartConfiguration,
type SortDirection,
} from "../metrics/QueryWidget";
import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors";
export const defaultChartConfig: ChartConfiguration = {
chartType: "bar",
@@ -32,6 +23,7 @@ export const defaultChartConfig: ChartConfiguration = {
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
};
interface ChartConfigPanelProps {
@@ -329,60 +321,86 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
) : (
<div className="flex flex-col gap-1.5">
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map(
(col, index) => (
<div key={index} className="flex items-center gap-1">
<Select
value={col}
setValue={(value) => {
const newColumns = [...config.yAxisColumns];
if (value) {
// If this is a new slot (empty string), add it
if (index >= config.yAxisColumns.length) {
newColumns.push(value);
} else {
newColumns[index] = value;
}
} else if (index < config.yAxisColumns.length) {
newColumns.splice(index, 1);
}
updateConfig({ yAxisColumns: newColumns });
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map((col, index) => (
<div key={index} className="flex items-center gap-1">
{col && !config.groupByColumn && (
<SeriesColorPicker
color={config.seriesColors?.[col] ?? getSeriesColor(index)}
onColorChange={(color) => {
updateConfig({
seriesColors: { ...config.seriesColors, [col]: color },
});
}}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions.filter(
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
)}
dropdownIcon
className="min-w-[140px] flex-1"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
/>
)}
<Select
value={col}
setValue={(value) => {
const newColumns = [...config.yAxisColumns];
const updates: Partial<ChartConfiguration> = {};
if (value) {
// If this is a new slot (empty string), add it
if (index >= config.yAxisColumns.length) {
newColumns.push(value);
} else {
// If the column name changed, migrate the color
const oldCol = newColumns[index];
if (oldCol && oldCol !== value && config.seriesColors?.[oldCol]) {
const newSeriesColors = { ...config.seriesColors };
newSeriesColors[value] = newSeriesColors[oldCol];
delete newSeriesColors[oldCol];
updates.seriesColors = newSeriesColors;
}
newColumns[index] = value;
}
} else if (index < config.yAxisColumns.length) {
newColumns.splice(index, 1);
}
</Select>
{index > 0 && (
<button
type="button"
onClick={() => {
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
updateConfig({ yAxisColumns: newColumns });
}}
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Remove series"
>
<XIcon className="h-3.5 w-3.5" />
</button>
updateConfig({ ...updates, yAxisColumns: newColumns });
}}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions.filter(
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
)}
</div>
)
)}
dropdownIcon
className="min-w-[140px] flex-1"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
{index > 0 && (
<button
type="button"
onClick={() => {
const removedCol = config.yAxisColumns[index];
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
const updates: Partial<ChartConfiguration> = { yAxisColumns: newColumns };
// Clean up the color entry for the removed series
if (removedCol && config.seriesColors?.[removedCol]) {
const newSeriesColors = { ...config.seriesColors };
delete newSeriesColors[removedCol];
updates.seriesColors = newSeriesColors;
}
updateConfig(updates);
}}
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Remove series"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{/* Add another series button - only show when we have at least one series and not grouped */}
{config.yAxisColumns.length > 0 &&
@@ -439,9 +457,7 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
{/* Group By - disabled when multiple series are selected */}
<ConfigField label="Group by">
{config.yAxisColumns.length > 1 ? (
<span className="text-xs text-text-dimmed">
Not available with multiple series
</span>
<span className="text-xs text-text-dimmed">Not available with multiple series</span>
) : (
<Select
value={config.groupByColumn ?? "__none__"}
@@ -569,6 +585,52 @@ function SortDirectionToggle({
);
}
function SeriesColorPicker({
color,
onColorChange,
}: {
color: string;
onColorChange: (color: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex-shrink-0 rounded p-0.5 hover:bg-charcoal-700"
title="Change series color"
>
<span
className="block h-4 w-4 rounded-full border border-white/30"
style={{ backgroundColor: color }}
/>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<div className="grid grid-cols-6 gap-1.5">
{CHART_COLORS_BY_HUE.map((c) => (
<button
key={c}
type="button"
onClick={() => {
onColorChange(c);
setOpen(false);
}}
className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30"
style={{ backgroundColor: c }}
title={c}
>
{c === color && <CheckIcon className="h-3.5 w-3.5 text-white drop-shadow-md" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}
function TypeBadge({ type }: { type: string }) {
// Simplify type for display
let displayType = type;
@@ -3,58 +3,22 @@ import { memo, useMemo } from "react";
import type { ChartConfig } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
import { Paragraph } from "../primitives/Paragraph";
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
// Color palette for chart series - 30 distinct colors for large datasets
const CHART_COLORS = [
// Primary colors
"#7655fd", // Purple
"#22c55e", // Green
"#f59e0b", // Amber
"#ef4444", // Red
"#06b6d4", // Cyan
"#ec4899", // Pink
"#8b5cf6", // Violet
"#14b8a6", // Teal
"#f97316", // Orange
"#6366f1", // Indigo
// Extended palette
"#84cc16", // Lime
"#0ea5e9", // Sky
"#f43f5e", // Rose
"#a855f7", // Fuchsia
"#eab308", // Yellow
"#10b981", // Emerald
"#3b82f6", // Blue
"#d946ef", // Magenta
"#78716c", // Stone
"#facc15", // Gold
// Additional distinct colors
"#2dd4bf", // Turquoise
"#fb923c", // Light orange
"#a3e635", // Yellow-green
"#38bdf8", // Light blue
"#c084fc", // Light purple
"#4ade80", // Light green
"#fbbf24", // Light amber
"#f472b6", // Light pink
"#67e8f9", // Light cyan
"#818cf8", // Light indigo
];
function getSeriesColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
import { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
import { getSeriesColor } from "./chartColors";
interface QueryResultsChartProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: ChartConfiguration;
/** The effective time range from the query filter (used to show the full x-axis period) */
timeRange?: { from: string; to: string };
fullLegend?: boolean;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
legendScrollable?: boolean;
isLoading?: boolean;
}
interface TransformedData {
@@ -153,12 +117,41 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
}
}
/**
* Snap a millisecond value up to the nearest "nice" interval
*/
function snapToNiceInterval(ms: number): number {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
if (ms <= SECOND) return SECOND;
if (ms <= 5 * SECOND) return 5 * SECOND;
if (ms <= 10 * SECOND) return 10 * SECOND;
if (ms <= 15 * SECOND) return 15 * SECOND;
if (ms <= 30 * SECOND) return 30 * SECOND;
if (ms <= MINUTE) return MINUTE;
if (ms <= 5 * MINUTE) return 5 * MINUTE;
if (ms <= 10 * MINUTE) return 10 * MINUTE;
if (ms <= 15 * MINUTE) return 15 * MINUTE;
if (ms <= 30 * MINUTE) return 30 * MINUTE;
if (ms <= HOUR) return HOUR;
if (ms <= 2 * HOUR) return 2 * HOUR;
if (ms <= 4 * HOUR) return 4 * HOUR;
if (ms <= 6 * HOUR) return 6 * HOUR;
if (ms <= 12 * HOUR) return 12 * HOUR;
if (ms <= DAY) return DAY;
return ms;
}
/**
* Detect the most common interval between consecutive data points
* This helps us understand the natural granularity of the data
*/
function detectDataInterval(timestamps: number[]): number {
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
if (timestamps.length < 2) return 24 * 60 * 60 * 1000; // Default to 1 day
const sorted = [...timestamps].sort((a, b) => a - b);
const gaps: number[] = [];
@@ -176,25 +169,7 @@ function detectDataInterval(timestamps: number[]): number {
// We use the minimum gap as a heuristic for the data interval
const minGap = Math.min(...gaps);
// Round to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// Snap to common intervals
if (minGap <= MINUTE) return MINUTE;
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
if (minGap <= HOUR) return HOUR;
if (minGap <= 2 * HOUR) return 2 * HOUR;
if (minGap <= 4 * HOUR) return 4 * HOUR;
if (minGap <= 6 * HOUR) return 6 * HOUR;
if (minGap <= 12 * HOUR) return 12 * HOUR;
if (minGap <= DAY) return DAY;
return minGap;
return snapToNiceInterval(minGap);
}
/**
@@ -218,20 +193,7 @@ function fillTimeGaps(
// If filling would create too many points, increase the interval to stay within limits
let effectiveInterval = interval;
if (estimatedPoints > maxPoints) {
effectiveInterval = Math.ceil(range / maxPoints);
// Round up to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
else effectiveInterval = 24 * HOUR;
effectiveInterval = snapToNiceInterval(Math.ceil(range / maxPoints));
}
// Create a map to collect values for each bucket (for aggregation)
@@ -390,22 +352,32 @@ function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): numb
}
/**
* Formats a date for tooltips (always shows full precision)
* Formats a date for tooltips and legend headers.
* Always includes time when the data point has a non-midnight time,
* so hovering a specific bar at e.g. 14:00 shows the full timestamp
* even when the axis labels only show the day.
* Seconds are shown whenever the granularity is "seconds" or the
* specific data point has non-zero seconds.
*/
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
// For shorter time ranges, include time
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0;
const hasSeconds = date.getSeconds() !== 0;
if (
granularity === "seconds" ||
(hasTime && granularity !== "months" && granularity !== "years")
) {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: granularity === "seconds" ? "2-digit" : undefined,
second: granularity === "seconds" || hasSeconds ? "2-digit" : undefined,
hour12: false,
});
}
// For longer ranges, just show date
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
@@ -463,7 +435,8 @@ function tryParseDate(value: unknown): Date | null {
*/
function transformDataForChart(
rows: Record<string, unknown>[],
config: ChartConfiguration
config: ChartConfiguration,
timeRange?: { from: string; to: string }
): TransformedData {
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
@@ -489,24 +462,37 @@ function transformDataForChart(
}
// Determine if X-axis is date-based (most values should be parseable as dates)
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
// When there are no results but a timeRange is provided, treat as date-based
const isDateBased =
rows.length === 0 && timeRange ? true : dateValues.length >= rows.length * 0.8; // At least 80% are dates
// Detect granularity from the full time range when available, otherwise from data
const granularity = isDateBased
? timeRange
? detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)])
: detectTimeGranularity(dateValues)
: "days";
// For date-based axes, use a special key for the timestamp
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
// Calculate time domain and ticks for date-based axes
// When a timeRange is provided (from the query filter), use it so the chart
// shows the full requested period rather than just the range of returned data.
let timeDomain: [number, number] | null = null;
let timeTicks: number[] | null = null;
if (isDateBased && dateValues.length > 0) {
const timestamps = dateValues.map((d) => d.getTime());
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
// Raw min/max used for gap filling (without padding)
let rawMinTime = 0;
let rawMaxTime = 0;
if (isDateBased && (dateValues.length > 0 || timeRange)) {
const dataTimestamps = dateValues.map((d) => d.getTime());
rawMinTime = timeRange ? new Date(timeRange.from).getTime() : Math.min(...dataTimestamps);
rawMaxTime = timeRange ? new Date(timeRange.to).getTime() : Math.max(...dataTimestamps);
// Add a small padding (2% on each side) so points aren't at the very edge
const padding = (maxTime - minTime) * 0.02;
timeDomain = [minTime - padding, maxTime + padding];
const padding = (rawMaxTime - rawMinTime) * 0.02;
timeDomain = [rawMinTime - padding, rawMaxTime + padding];
// Generate evenly-spaced ticks across the entire range using nice intervals
timeTicks = generateTimeTicks(minTime, maxTime);
timeTicks = generateTimeTicks(rawMinTime, rawMaxTime);
}
// Helper to format X value for categorical axes (non-date)
@@ -564,13 +550,27 @@ function transformDataForChart(
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
// When filling across a full time range, ensure the interval is appropriate
// for the range size (target ~150 points) so we don't create overly dense charts
const rangeMs = rawMaxTime - rawMinTime;
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / 150) : 0;
// Also cap the interval so we get enough data points to visually represent
// the full time range. Without this, limited data (e.g. 1 point) defaults
// to a 1-day interval which can be far too coarse for shorter ranges,
// producing too few bars/points and potentially buckets outside the domain.
const maxRangeInterval =
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
const effectiveInterval = Math.min(
Math.max(dataInterval, minRangeInterval),
maxRangeInterval
);
data = fillTimeGaps(
data,
xDataKey,
yAxisColumns,
timeDomain[0],
timeDomain[1],
dataInterval,
rawMinTime,
rawMaxTime,
effectiveInterval,
granularity,
aggregation
);
@@ -633,13 +633,27 @@ function transformDataForChart(
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
// When filling across a full time range, ensure the interval is appropriate
// for the range size (target ~150 points) so we don't create overly dense charts
const rangeMs = rawMaxTime - rawMinTime;
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / 150) : 0;
// Also cap the interval so we get enough data points to visually represent
// the full time range. Without this, limited data (e.g. 1 point) defaults
// to a 1-day interval which can be far too coarse for shorter ranges,
// producing too few bars/points and potentially buckets outside the domain.
const maxRangeInterval =
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
const effectiveInterval = Math.min(
Math.max(dataInterval, minRangeInterval),
maxRangeInterval
);
data = fillTimeGaps(
data,
xDataKey,
series,
timeDomain[0],
timeDomain[1],
dataInterval,
rawMinTime,
rawMaxTime,
effectiveInterval,
granularity,
aggregation
);
@@ -725,8 +739,10 @@ export const QueryResultsChart = memo(function QueryResultsChart({
rows,
columns,
config,
timeRange,
fullLegend = false,
onViewAllLegendItems,
isLoading = false,
legendScrollable = false,
}: QueryResultsChartProps) {
const {
@@ -748,7 +764,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
xDataKey,
timeDomain,
timeTicks,
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
} = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]);
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
const data = useMemo(() => {
@@ -759,13 +775,19 @@ export const QueryResultsChart = memo(function QueryResultsChart({
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
// Detect time granularity for the data
const timeGranularity = useMemo(
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
[dateValues]
);
// Detect time granularity — use the full time range when available so tick
// labels are appropriate for the period (e.g. "Jan 5" for a 7-day range
// instead of just "16:00:00" when data is sparse)
const timeGranularity = useMemo(() => {
if (timeRange) {
return detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)]);
}
return dateValues.length > 0 ? detectTimeGranularity(dateValues) : null;
}, [dateValues, timeRange]);
// X-axis tick formatter for date-based axes
// X-axis tick formatter for date-based axes (pure no deduplication).
// Label deduplication is handled inside dateAxisTick below so that the
// mutable "lastLabel" state is correctly reset on each Recharts render pass.
const xAxisTickFormatter = useMemo(() => {
if (!isDateBased || !timeGranularity) return undefined;
return (value: number) => {
@@ -777,17 +799,25 @@ export const QueryResultsChart = memo(function QueryResultsChart({
// Create dynamic Y-axis formatter based on data range
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
// Check if the group-by column has a runStatus customRenderType
const groupByIsRunStatus = useMemo(() => {
if (!groupByColumn) return false;
const col = columns.find((c) => c.name === groupByColumn);
return col?.customRenderType === "runStatus";
}, [groupByColumn, columns]);
// Build chart config for colors/labels
const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
series.forEach((s, i) => {
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
cfg[s] = {
label: s,
color: getSeriesColor(i),
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(i),
};
});
return cfg;
}, [series]);
}, [series, groupByIsRunStatus, config.seriesColors]);
// Custom tooltip label formatter for better date display
const tooltipLabelFormatter = useMemo(() => {
@@ -831,7 +861,91 @@ export const QueryResultsChart = memo(function QueryResultsChart({
return [min, "auto"] as [number, string];
}, [data, series]);
// Validation
// Angle all date-based labels for consistent appearance and to avoid overlap
const xAxisAngle = isDateBased ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 65 : undefined;
// Check if the data would produce duplicate labels at the current granularity.
// Only use the custom tick renderer (with interval:0) when duplicates exist,
// otherwise let Recharts handle label spacing to avoid collisions.
const hasDuplicateLabels = useMemo(() => {
if (!isDateBased || !timeGranularity || data.length === 0) return false;
const labels = new Set<string>();
for (const point of data) {
const ts = point.__timestamp ?? point[xDataKey];
if (typeof ts === "number") {
labels.add(formatDateByGranularity(new Date(ts), timeGranularity));
}
}
return labels.size < data.length;
}, [isDateBased, timeGranularity, data, xDataKey]);
// Custom tick renderer for date-based axes: renders a tick mark alongside
// each label, and for unlabelled points (de-duplicated) just a subtle tick mark.
// De-duplication lives here (not in xAxisTickFormatter) so that the mutable
// lastLabel is reset when Recharts starts a new render pass (index === 0).
const dateAxisTick = useMemo(() => {
if (!isDateBased || !xAxisTickFormatter) return undefined;
let lastLabel = "";
return (props: Record<string, unknown>) => {
const { x, y, payload, index } = props as {
x: number;
y: number;
payload: { value: number };
index: number;
};
// Reset dedup state at the start of each Recharts render pass
if (index === 0) lastLabel = "";
const formatted = xAxisTickFormatter(payload.value);
const label = formatted === lastLabel ? "" : formatted;
lastLabel = formatted;
// y is the tick text position, offset from the axis by tickMargin + internal padding
const axisY = (y as number) - 12;
if (label) {
return (
<g>
<line
x1={x as number}
y1={axisY}
x2={x as number}
y2={axisY - 3}
stroke="#878C99"
strokeWidth={1}
/>
<text
x={x}
y={axisY}
dy={10}
fill="#878C99"
fontSize={11}
textAnchor={xAxisAngle !== 0 ? "end" : "middle"}
style={{ fontVariantNumeric: "tabular-nums" }}
transform={
xAxisAngle !== 0 ? `rotate(${xAxisAngle}, ${x}, ${axisY + 10})` : undefined
}
>
{label}
</text>
</g>
);
}
// Small tick mark sitting on the axis baseline, pointing upward
return (
<line
x1={x as number}
y1={axisY}
x2={x as number}
y2={axisY - 3}
stroke="#272A2E"
strokeWidth={1}
/>
);
};
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);
// Validation — all hooks must be above this point
if (!xAxisColumn) {
return <EmptyState message="Select an X-axis column to display the chart" />;
}
@@ -848,13 +962,18 @@ export const QueryResultsChart = memo(function QueryResultsChart({
return <EmptyState message="Unable to transform data for chart" />;
}
// Determine appropriate angle for X-axis labels based on granularity
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
// Base x-axis props shared by all chart types
const baseXAxisProps = {
tickFormatter: xAxisTickFormatter,
...(dateAxisTick
? {
tick: dateAxisTick,
tickLine: false,
tickFormatter: undefined,
// Only force every tick to render when there are duplicates to de-duplicate;
// otherwise let Recharts auto-space to avoid label collisions
...(hasDuplicateLabels ? { interval: 0 } : {}),
}
: { tickFormatter: xAxisTickFormatter }),
angle: xAxisAngle,
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
height: xAxisHeight,
@@ -864,13 +983,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
// This properly represents time gaps between data points
const xAxisPropsForLine = isDateBased
? {
type: "number" as const,
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
...baseXAxisProps,
}
type: "number" as const,
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
...baseXAxisProps,
}
: baseXAxisProps;
// Bar charts always use categorical axis positioning
@@ -899,6 +1018,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
>
<Chart.Bar
xAxisProps={xAxisPropsForBar}
@@ -924,6 +1044,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
>
<Chart.Line
xAxisProps={xAxisPropsForLine}
+41 -2
View File
@@ -284,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) {
}}
/>
{showButtons && (
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-1.5">
{additionalActions && additionalActions}
{showFormatButton && (
<Button
@@ -338,11 +338,50 @@ export function TSQLEditor(opts: TSQLEditorProps) {
);
}
// SQL keywords that legitimately appear before parentheses with a space
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
"IN",
"NOT",
"EXISTS",
"OVER",
"USING",
"VALUES",
"BETWEEN",
"LIKE",
"AND",
"OR",
"ON",
"SET",
"INTO",
"TABLE",
"CASE",
"WHEN",
"THEN",
"ELSE",
"AS",
"FROM",
"WHERE",
"HAVING",
"JOIN",
"SELECT",
]);
export function autoFormatSQL(sql: string) {
return formatSQL(sql, {
let formatted = formatSQL(sql, {
language: "sql",
keywordCase: "upper",
indentStyle: "standard",
linesBetweenQueries: 2,
});
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
// Remove that space for anything that isn't a SQL keyword
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
return match;
}
return `${name}(`;
});
return formatted;
}
@@ -1,19 +1,21 @@
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { IconFilter2, IconFilter2X } from "@tabler/icons-react";
import { rankItem } from "@tanstack/match-sorter-utils";
import {
useReactTable,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
flexRender,
type ColumnDef,
useReactTable,
type CellContext,
type ColumnResizeMode,
type ColumnFiltersState,
type FilterFn,
type Column,
type SortingState,
type ColumnDef,
type ColumnFiltersState,
type ColumnResizeMode,
type FilterFn,
type SortDirection,
type SortingState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
@@ -39,12 +41,6 @@ import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "../primitives/Tooltip";
import { QueueName } from "../runs/v3/QueueName";
import {
FunnelIcon,
ChevronUpIcon,
ChevronDownIcon,
ChevronUpDownIcon,
} from "@heroicons/react/20/solid";
const MAX_STRING_DISPLAY_LENGTH = 64;
const ROW_HEIGHT = 33; // Estimated row height in pixels
@@ -54,7 +50,7 @@ const MIN_COLUMN_WIDTH = 60;
const MAX_COLUMN_WIDTH = 400;
const CHAR_WIDTH_PX = 7.5; // Approximate width of a monospace character at text-xs (12px)
const CELL_PADDING_PX = 40; // px-2 (8px) on each side + buffer for copy button
const HEADER_ICONS_WIDTH_PX = 72; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (16px)
const HEADER_ICONS_WIDTH_PX = 80; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (24px)
const SAMPLE_SIZE = 100; // Number of rows to sample for width calculation
// Type for row data
@@ -160,10 +156,10 @@ const fuzzyFilter: FilterFn<RowData> = (row, columnId, value, addMeta) => {
cellValue === null
? "NULL"
: cellValue === undefined
? ""
: typeof cellValue === "object"
? JSON.stringify(cellValue)
: String(cellValue);
? ""
: typeof cellValue === "object"
? JSON.stringify(cellValue)
: String(cellValue);
// Build searchable strings - formatted value (if we have column metadata)
const formattedValue = meta?.outputColumn
@@ -462,6 +458,7 @@ function CellValue({
</pre>
}
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
disableHoverableContent
/>
);
}
@@ -482,7 +479,14 @@ function CellValue({
switch (column.customRenderType) {
case "runId": {
if (typeof value === "string") {
return <TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>;
return (
<SimpleTooltip
content="Jump to run"
disableHoverableContent
hidden={!hovered}
button={<TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>}
/>
);
}
break;
}
@@ -490,19 +494,17 @@ function CellValue({
const status = isTaskRunStatus(value)
? value
: isRunFriendlyStatus(value)
? runStatusFromFriendlyTitle(value)
: undefined;
? runStatusFromFriendlyTitle(value)
: undefined;
if (status) {
if (hovered) {
return (
<SimpleTooltip
content={descriptionForTaskRunStatus(status)}
disableHoverableContent
button={<TaskRunStatusCombo status={status} />}
/>
);
}
return <TaskRunStatusCombo status={status} />;
return (
<SimpleTooltip
content={descriptionForTaskRunStatus(status)}
disableHoverableContent
hidden={!hovered}
button={<TaskRunStatusCombo status={status} />}
/>
);
}
break;
}
@@ -607,6 +609,7 @@ function CellValue({
{truncateString(arrayString)}
</span>
}
disableHoverableContent
/>
);
}
@@ -642,6 +645,7 @@ function CellValue({
</pre>
}
button={<span>{truncateString(stringValue)}</span>}
disableHoverableContent
/>
);
}
@@ -688,6 +692,7 @@ function JSONCellValue({ value }: { value: unknown }) {
button={
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
}
disableHoverableContent
/>
);
}
@@ -714,14 +719,14 @@ function CopyableCell({
<div
className={cn(
"relative flex w-full items-center overflow-hidden px-2 py-1.5",
"bg-background-dimmed group-hover/row:bg-charcoal-800",
"bg-background-bright group-hover/row:bg-charcoal-750",
"font-mono text-xs text-text-dimmed group-hover/row:text-text-bright",
alignment === "right" && "justify-end"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className="truncate">{children}</span>
<span className="flex h-4 items-center truncate">{children}</span>
{isHovered && (
<span
onClick={(e) => {
@@ -781,18 +786,21 @@ function HeaderCellContent({
onSortClick?: (event: React.MouseEvent) => void;
canSort?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
const [isCellHovered, setIsCellHovered] = useState(false);
const [isFilterHovered, setIsFilterHovered] = useState(false);
const sortHighlighted = isCellHovered && !isFilterHovered;
return (
<div
className={cn(
"flex w-full items-center gap-1 overflow-hidden bg-background-dimmed py-1.5 pl-2 pr-1",
"flex w-full items-center gap-1 overflow-hidden bg-background-bright py-2 pl-2 pr-3",
"font-mono text-xs font-medium text-text-bright",
alignment === "right" && "justify-end",
canSort && "cursor-pointer select-none"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onMouseEnter={() => setIsCellHovered(true)}
onMouseLeave={() => setIsCellHovered(false)}
onClick={onSortClick}
>
{tooltip ? (
@@ -802,11 +810,14 @@ function HeaderCellContent({
})}
>
<span className="truncate text-left">{children}</span>
<InfoIconTooltip
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isHovered}
/>
<span className="flex flex-shrink-0">
<InfoIconTooltip
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isCellHovered}
disableHoverableContent
/>
</span>
</div>
) : (
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
@@ -814,7 +825,10 @@ function HeaderCellContent({
{/* Sort indicator */}
{canSort && (
<span
className={cn("flex-shrink-0", sortDirection ? "text-text-bright" : "text-text-dimmed")}
className={cn(
"flex-shrink-0 transition-colors",
sortHighlighted ? "text-text-bright" : "text-text-dimmed"
)}
>
{sortDirection === "asc" ? (
<ChevronUpIcon className="size-4" />
@@ -831,10 +845,12 @@ function HeaderCellContent({
e.stopPropagation();
onFilterClick();
}}
className="flex-shrink-0 rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
onMouseEnter={() => setIsFilterHovered(true)}
onMouseLeave={() => setIsFilterHovered(false)}
className="flex-shrink-0 rounded text-text-dimmed transition-colors hover:text-text-bright"
title="Toggle column filters"
>
<FunnelIcon className="size-3" />
{showFilters ? <IconFilter2X className="size-4" /> : <IconFilter2 className="size-4" />}
</button>
)}
</div>
@@ -866,7 +882,7 @@ function FilterCell({
}, [shouldFocus, onFocused]);
return (
<div className="flex items-center bg-background-dimmed px-1.5 pb-1" style={{ width }}>
<div className="flex items-center bg-background-bright px-1.5 pb-2" style={{ width }}>
<DebouncedInput
ref={inputRef}
value={columnFilterValue ?? ""}
@@ -886,10 +902,12 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
rows,
columns,
prettyFormatting = true,
sorting: defaultSorting = [],
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
prettyFormatting?: boolean;
sorting?: SortingState;
}) {
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -899,7 +917,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
// Track which column's filter should be focused
const [focusFilterColumn, setFocusFilterColumn] = useState<string | null>(null);
// State for column sorting
const [sorting, setSorting] = useState<SortingState>([]);
const [sorting, setSorting] = useState<SortingState>(defaultSorting);
// Create TanStack Table column definitions from OutputColumnMetadata
// Calculate column widths based on content
@@ -966,7 +984,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
>
<table style={{ display: "grid" }}>
<thead
className="bg-background-dimmed"
className="border-t border-grid-bright bg-background-bright"
style={{
display: "grid",
position: "sticky",
@@ -1038,7 +1056,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
</tr>
)}
</thead>
<tbody style={{ display: "grid" }}>
<tbody className="border-b border-grid-bright" style={{ display: "grid" }}>
<tr style={{ display: "flex" }}>
<td>
<Paragraph variant="extra-small" className="p-4 text-text-dimmed">
@@ -1060,7 +1078,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
>
<table style={{ display: "grid" }}>
<thead
className="bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
style={{
display: "grid",
position: "sticky",
@@ -1107,7 +1125,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none",
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
"opacity-0 group-hover/header:opacity-100",
"bg-charcoal-600 hover:bg-indigo-500",
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
@@ -1139,7 +1157,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
className="bg-background-dimmed divide-y divide-charcoal-700"
className="divide-y divide-charcoal-700 bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:z-[1] after:h-px after:bg-grid-bright"
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
@@ -1147,7 +1165,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
<tr
key={row.id}
data-index={virtualRow.index}
className="group/row hover:bg-charcoal-800"
className="group/row hover:bg-charcoal-750"
style={{
display: "flex",
position: "absolute",
@@ -0,0 +1,183 @@
/**
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
*
* HSL is a human-friendly color model:
* h: 0360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
* s: 0100 (saturation — 0 is gray, 100 is full color)
* l: 0100 (lightness — 0 is black, 50 is pure color, 100 is white)
*/
interface HSLColor {
h: number;
s: number;
l: number;
}
interface ChartColorDef {
name: string;
hsl: HSLColor;
}
// ---------------------------------------------------------------------------
// Palette — 30 distinct colors for chart series, defined in HSL
// ---------------------------------------------------------------------------
const CHART_COLOR_DEFS: ChartColorDef[] = [
// Primary colors (high contrast, spread across hue wheel)
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
// Extended palette
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
// Additional distinct colors (lighter variants)
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
];
// ---------------------------------------------------------------------------
// HSL ↔ Hex conversion
// ---------------------------------------------------------------------------
/** Convert an HSL color (h: 0360, s: 0100, l: 0100) to a hex string */
function hslToHex({ h, s, l }: HSLColor): string {
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const hPrime = h / 60;
const x = c * (1 - Math.abs((hPrime % 2) - 1));
const m = lNorm - c / 2;
let r1: number, g1: number, b1: number;
if (hPrime < 1) {
r1 = c;
g1 = x;
b1 = 0;
} else if (hPrime < 2) {
r1 = x;
g1 = c;
b1 = 0;
} else if (hPrime < 3) {
r1 = 0;
g1 = c;
b1 = x;
} else if (hPrime < 4) {
r1 = 0;
g1 = x;
b1 = c;
} else if (hPrime < 5) {
r1 = x;
g1 = 0;
b1 = c;
} else {
r1 = c;
g1 = 0;
b1 = x;
}
const toHex = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, "0");
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
}
/** Convert a hex string to HSL (h: 0360, s: 0100, l: 0100) */
function hexToHsl(hex: string): HSLColor {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
const l = (max + min) / 2;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const s = delta / (1 - Math.abs(2 * l - 1));
let h: number;
if (max === r) {
h = 60 * (((g - b) / delta + 6) % 6);
} else if (max === g) {
h = 60 * ((b - r) / delta + 2);
} else {
h = 60 * ((r - g) / delta + 4);
}
return {
h: Math.round(h),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
// ---------------------------------------------------------------------------
// Derived hex palette (for consumers that need plain hex strings)
// ---------------------------------------------------------------------------
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
/** Get the hex color for a series by its index (wraps around) */
export function getSeriesColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
// ---------------------------------------------------------------------------
// Hue-sorted palette (rainbow order for color pickers)
// ---------------------------------------------------------------------------
const SATURATION_THRESHOLD = 10;
/**
* Chart colors sorted by perceived hue — the natural rainbow order
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
*
* Very desaturated colors (like grays) are placed at the end since they don't
* have a strong hue.
*/
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
.sort((a, b) => {
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
// Push desaturated colors to the end
if (aIsGray && !bIsGray) return 1;
if (!aIsGray && bIsGray) return -1;
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
// Sort by hue, then by saturation (more vivid first), then by lightness
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
return a.hsl.l - b.hsl.l;
})
.map((def) => hslToHex(def.hsl));
@@ -123,6 +123,16 @@ function createFunctionCompletions(): Completion[] {
});
}
// Add special TSQL functions not in the ClickHouse function registry
functions.push({
label: "timeBucket",
type: "function",
detail: "auto time bucket (0 args)",
apply: "timeBucket()",
boost: 1.5,
info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.",
});
return functions;
}
@@ -21,7 +21,7 @@ export function MainBody({ children }: { children: React.ReactNode }) {
/** This container should be placed around the content on a page */
export function PageContainer({ children }: { children: React.ReactNode }) {
return <div className="grid grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
return <div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
}
export function PageBody({
@@ -43,7 +43,7 @@ export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
shortcut={shortcut}
tooltipTitle="Filter by task"
>
Tasks
<span className="ml-0.5">Tasks</span>
</SelectTrigger>
}
searchValue={search}
@@ -114,7 +114,7 @@ function TasksDropdown({
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
@@ -0,0 +1,496 @@
import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
import { ClipboardIcon } from "@heroicons/react/24/outline";
import { ChartBarIcon } from "@heroicons/react/24/solid";
import { type OutputColumnMetadata } from "@internal/tsql";
import { DialogClose } from "@radix-ui/react-dialog";
import { IconBraces, IconChartHistogram, IconFileTypeCsv } from "@tabler/icons-react";
import { assertNever } from "assert-never";
import { Maximize2 } from "lucide-react";
import { useCallback, useState, type ReactNode } from "react";
import { z } from "zod";
import { Card } from "~/components/primitives/charts/Card";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { cn } from "~/utils/cn";
import { rowsToCSV, rowsToJSON } from "~/utils/dataExport";
import { QueryResultsChart } from "../code/QueryResultsChart";
import { TSQLResultsTable } from "../code/TSQLResultsTable";
import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { BigNumberCard } from "../primitives/charts/BigNumberCard";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { LoadingBarDivider } from "../primitives/LoadingBarDivider";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
} from "../primitives/Popover";
const ChartType = z.union([z.literal("bar"), z.literal("line")]);
export type ChartType = z.infer<typeof ChartType>;
const SortDirection = z.union([z.literal("asc"), z.literal("desc")]);
export type SortDirection = z.infer<typeof SortDirection>;
const AggregationType = z.union([
z.literal("sum"),
z.literal("avg"),
z.literal("count"),
z.literal("min"),
z.literal("max"),
]);
export type AggregationType = z.infer<typeof AggregationType>;
const chartConfigOptions = {
chartType: ChartType,
xAxisColumn: z.string().nullable(),
yAxisColumns: z.string().array(),
groupByColumn: z.string().nullable(),
stacked: z.boolean(),
sortByColumn: z.string().nullable(),
sortDirection: SortDirection,
aggregation: AggregationType,
seriesColors: z.record(z.string()).optional(),
};
const ChartConfiguration = z.object({ ...chartConfigOptions });
export type ChartConfiguration = z.infer<typeof ChartConfiguration>;
const BigNumberAggregationType = z.union([
z.literal("sum"),
z.literal("avg"),
z.literal("count"),
z.literal("min"),
z.literal("max"),
z.literal("first"),
z.literal("last"),
]);
export type BigNumberAggregationType = z.infer<typeof BigNumberAggregationType>;
const BigNumberSortDirection = z.union([z.literal("asc"), z.literal("desc")]);
const bigNumberConfigOptions = {
column: z.string(),
aggregation: BigNumberAggregationType,
sortDirection: BigNumberSortDirection.optional(),
abbreviate: z.boolean().default(false),
prefix: z.string().optional(),
suffix: z.string().optional(),
};
const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions });
export type BigNumberConfiguration = z.infer<typeof BigNumberConfiguration>;
export const QueryWidgetConfig = z.discriminatedUnion("type", [
z.object({
type: z.literal("table"),
prettyFormatting: z.boolean().default(true),
sorting: z
.array(
z.object({
desc: z.boolean(),
id: z.string(),
})
)
.default([]),
}),
z.object({
type: z.literal("chart"),
...chartConfigOptions,
}),
z.object({
type: z.literal("bignumber"),
...bigNumberConfigOptions,
}),
z.object({
type: z.literal("title"),
}),
]);
export type QueryWidgetConfig = z.infer<typeof QueryWidgetConfig>;
/** Result data containing rows and column metadata */
export type QueryWidgetData = {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
};
/** Widget configuration with optional result data (used for edit callbacks) */
export type WidgetData = {
title: string;
query: string;
display: QueryWidgetConfig;
/** The current result data from the widget */
resultData?: QueryWidgetData;
};
export type QueryWidgetProps = {
title: ReactNode;
/** String title for rename dialog (optional - if not provided, rename won't be available) */
titleString?: string;
/** The TSQL query string (used for "Copy query" in the menu) */
query?: string;
isLoading?: boolean;
error?: string;
data: QueryWidgetData;
config: QueryWidgetConfig;
/** The effective time range for the query (used to show full x-axis on time-based charts) */
timeRange?: { from: string; to: string };
accessory?: ReactNode;
isResizing?: boolean;
isDraggable?: boolean;
/** Callback when edit is clicked. Receives the current data. */
onEdit?: (data: QueryWidgetData) => void;
/** Callback when rename is clicked. Receives the new title. */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked. */
onDelete?: () => void;
/** Callback when duplicate is clicked. Receives the current data. */
onDuplicate?: (data: QueryWidgetData) => void;
};
export function QueryWidget({
title,
titleString,
query,
accessory,
isLoading,
error,
isResizing,
isDraggable,
onEdit,
onRename,
onDelete,
onDuplicate,
...props
}: QueryWidgetProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(titleString ?? "");
const hasEditActions = onEdit || onRename || onDelete || onDuplicate;
const hasData = props.data.rows.length > 0;
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text);
}, []);
const copyQuery = useCallback(() => {
if (query) {
copyToClipboard(query);
}
}, [query, copyToClipboard]);
const copyJSON = useCallback(() => {
copyToClipboard(rowsToJSON(props.data.rows));
}, [props.data.rows, copyToClipboard]);
const copyCSV = useCallback(() => {
copyToClipboard(rowsToCSV(props.data.rows, props.data.columns));
}, [props.data, copyToClipboard]);
return (
<div className="group h-full">
<Card className="h-full overflow-hidden px-0 pb-0">
<Card.Header draggable={isDraggable}>
<div className="flex items-center gap-1.5">{title}</div>
<Card.Accessory>
<SimpleTooltip
button={
<span className="opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="minimal/small"
LeadingIcon={Maximize2}
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
onClick={() => setIsFullscreen(true)}
className="!px-1"
/>
</span>
}
content="Maximize"
asChild
/>
{accessory}
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverVerticalEllipseTrigger
isOpen={isMenuOpen}
className={cn(
"transition-opacity",
isMenuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
/>
<PopoverContent align="end" className="p-0">
<div className="flex flex-col gap-1 p-1">
{hasEditActions && (
<>
{onEdit && (
<PopoverMenuItem
icon={IconChartHistogram}
title="Edit chart"
onClick={() => {
onEdit(props.data);
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
)}
{onRename && (
<PopoverMenuItem
icon={PencilSquareIcon}
title="Rename"
onClick={() => {
setRenameValue(titleString ?? "");
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
}}
/>
)}
{onDuplicate && (
<PopoverMenuItem
icon={DocumentDuplicateIcon}
title="Duplicate chart"
onClick={() => {
onDuplicate(props.data);
setIsMenuOpen(false);
}}
className="pr-4"
/>
)}
</>
)}
{query && (
<PopoverMenuItem
icon={ClipboardIcon}
title="Copy query"
onClick={() => {
copyQuery();
setIsMenuOpen(false);
}}
/>
)}
<PopoverMenuItem
icon={IconBraces}
title="Copy JSON"
disabled={!hasData}
onClick={() => {
copyJSON();
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
<PopoverMenuItem
icon={IconFileTypeCsv}
title="Copy CSV"
disabled={!hasData}
onClick={() => {
copyCSV();
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
{onDelete && (
<PopoverMenuItem
icon={TrashIcon}
title="Delete chart"
leadingIconClassName="text-error"
className="text-error hover:!bg-error/10"
onClick={() => {
onDelete();
setIsMenuOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
</Card.Accessory>
</Card.Header>
<LoadingBarDivider isLoading={isLoading ?? false} className="bg-transparent" />
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
{isResizing ? (
<div className="flex h-full flex-1 items-center justify-center p-3">
<div className="flex flex-col items-center gap-1 text-text-dimmed">
<ChartBarIcon className="size-10 text-text-dimmed" />{" "}
<span className="text-base font-medium">Resizing...</span>
</div>
</div>
) : error ? (
<div className="p-3">
<Callout variant="error">{error}</Callout>
</div>
) : (
<QueryWidgetBody
{...props}
title={title}
isFullscreen={isFullscreen}
setIsFullscreen={setIsFullscreen}
isLoading={isLoading ?? false}
/>
)}
</Card.Content>
</Card>
{/* Rename Dialog */}
{onRename && (
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Rename chart</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (renameValue.trim()) {
onRename(renameValue.trim());
setIsRenameDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Chart title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
}
type QueryWidgetBodyProps = {
title: ReactNode;
data: QueryWidgetData;
config: QueryWidgetConfig;
timeRange?: { from: string; to: string };
isFullscreen: boolean;
setIsFullscreen: (open: boolean) => void;
isLoading: boolean;
};
function QueryWidgetBody({
title,
data,
config,
timeRange,
isFullscreen,
setIsFullscreen,
isLoading,
}: QueryWidgetBodyProps) {
const type = config.type;
// Only show the loading state if we have no data yet (initial load).
// During a reload with existing data, keep showing the current data
// while the loading bar in the header indicates a refresh is in progress.
const hasData = data.rows.length > 0;
const showLoading = isLoading && !hasData;
switch (type) {
case "table": {
return (
<>
<TSQLResultsTable
rows={data.rows}
columns={data.columns}
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent
fullscreen
className="flex flex-col gap-0 bg-background-bright px-0 pb-0"
>
<DialogHeader className="px-4">{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 pt-2.5">
<TSQLResultsTable
rows={data.rows}
columns={data.columns}
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "chart": {
return (
<>
<QueryResultsChart
rows={data.rows}
columns={data.columns}
config={config}
timeRange={timeRange}
onViewAllLegendItems={() => setIsFullscreen(true)}
isLoading={showLoading}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
<QueryResultsChart
rows={data.rows}
columns={data.columns}
config={config}
timeRange={timeRange}
fullLegend
legendScrollable
isLoading={showLoading}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "bignumber": {
return (
<>
<BigNumberCard
rows={data.rows}
columns={data.columns}
config={config}
isLoading={showLoading}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="flex min-h-0 w-full flex-1 items-center justify-center pt-4">
<BigNumberCard
rows={data.rows}
columns={data.columns}
config={config}
isLoading={showLoading}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "title": {
// Title widgets are rendered by TitleWidget, not QueryWidget
return null;
}
default: {
assertNever(type);
}
}
}
@@ -0,0 +1,212 @@
import * as Ariakit from "@ariakit/react";
import { RectangleStackIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { matchSorter } from "match-sorter";
import { type ReactNode, useMemo } from "react";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { useDebounceEffect } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "q" };
export function QueuesFilter() {
const { values, replace, del } = useSearchParams();
const selectedQueues = values("queues");
if (selectedQueues.length === 0 || selectedQueues.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<SelectTrigger
icon={<RectangleStackIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by queue"
>
<span className="ml-1">Queues</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Queues"
icon={<RectangleStackIcon className="size-4" />}
value={appliedSummary(selectedQueues.map((v) => v.replace("task/", "")))}
onRemove={() => del(["queues"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function QueuesDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({
queues: values.length > 0 ? values : undefined,
});
};
const queueValues = values("queues").filter((v) => v !== "");
const selected = queueValues.length > 0 ? queueValues : undefined;
const fetcher = useFetcher<typeof queuesLoader>();
useDebounceEffect(
searchValue,
(s) => {
const searchParams = new URLSearchParams();
searchParams.set("per_page", "25");
if (searchValue) {
searchParams.set("query", s);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/queues?${searchParams.toString()}`
);
},
250
);
const filtered = useMemo(() => {
// Use a Map to deduplicate by value
const itemsMap = new Map<string, { name: string; type: "custom" | "task"; value: string }>();
// Add selected items first (for items not yet loaded from fetcher)
for (const queueName of selected ?? []) {
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
if (!queueItem) {
if (queueName.startsWith("task/")) {
itemsMap.set(queueName, {
name: queueName.replace("task/", ""),
type: "task",
value: queueName,
});
} else {
itemsMap.set(queueName, {
name: queueName,
type: "custom",
value: queueName,
});
}
}
}
// Add items from fetcher data
if (fetcher.data !== undefined) {
for (const q of fetcher.data.queues) {
const value = q.type === "task" ? `task/${q.name}` : q.name;
itemsMap.set(value, {
name: q.name,
type: q.type,
value,
});
}
}
const items = Array.from(itemsMap.values());
return matchSorter(items, searchValue, {
keys: ["name"],
});
}, [searchValue, fetcher.data, selected]);
return (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by queues..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((queue) => (
<SelectItem
key={queue.value}
value={queue.value}
icon={
queue.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
{queue.name}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No queues found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,177 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher, useNavigate } from "@remix-run/react";
import { IconChartHistogram } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { useEnvironment } from "~/hooks/useEnvironment";
import {
useCustomDashboards,
useOrganization,
useWidgetLimitPerDashboard,
} from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
import { Button } from "../primitives/Buttons";
import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog";
import { FormButtons } from "../primitives/FormButtons";
import { Paragraph } from "../primitives/Paragraph";
import type { QueryWidgetConfig } from "./QueryWidget";
export type SaveToDashboardDialogProps = {
title: string;
query: string;
config: QueryWidgetConfig;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
};
export function SaveToDashboardDialog({
title,
query,
config,
isOpen,
onOpenChange,
}: SaveToDashboardDialogProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const customDashboards = useCustomDashboards();
const widgetLimit = useWidgetLimitPerDashboard();
const fetcher = useFetcher<{ success: boolean }>();
const navigate = useNavigate();
// Find the first dashboard that isn't at the widget limit
const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
firstAvailableDashboard?.friendlyId ?? customDashboards[0]?.friendlyId ?? null
);
// Build the form action URL
const formAction = selectedDashboardId
? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/widgets`
: "";
const isLoading = fetcher.state === "submitting";
// Check if selected dashboard is at widget limit
const selectedDashboard = customDashboards.find((d) => d.friendlyId === selectedDashboardId);
const isSelectedAtLimit = selectedDashboard
? selectedDashboard.widgetCount >= widgetLimit
: false;
// Navigate to the dashboard when the fetcher completes successfully
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.success && selectedDashboardId) {
onOpenChange(false);
navigate(
v3CustomDashboardPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug },
{ friendlyId: selectedDashboardId }
)
);
}
}, [fetcher.state, fetcher.data, selectedDashboardId, onOpenChange, navigate, organization.slug, project.slug, environment.slug]);
// Update selection if dashboards change
useEffect(() => {
if (customDashboards.length > 0 && !selectedDashboardId) {
const available = customDashboards.find((d) => d.widgetCount < widgetLimit);
setSelectedDashboardId(available?.friendlyId ?? customDashboards[0].friendlyId);
}
}, [customDashboards, selectedDashboardId, widgetLimit]);
if (customDashboards.length === 0) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Add to dashboard</DialogHeader>
<div className="!mt-1 space-y-4">
<Paragraph variant="small" className="text-text-dimmed">
You don't have any custom dashboards yet. Create one first from the sidebar menu.
</Paragraph>
<FormButtons
className="justify-end"
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Close</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Add to dashboard</DialogHeader>
<fetcher.Form method="post" action={formAction} className="space-y-4">
<input type="hidden" name="action" value="add" />
<input type="hidden" name="title" value={title} />
<input type="hidden" name="query" value={query} />
<input type="hidden" name="config" value={JSON.stringify(config)} />
<div className="!mt-1 space-y-2">
<Paragraph variant="small" className="text-text-dimmed">
Select a dashboard to add this chart to:
</Paragraph>
<div className="max-h-64 space-y-1 overflow-y-auto">
{customDashboards.map((dashboard) => {
const isAtLimit = dashboard.widgetCount >= widgetLimit;
return (
<button
key={dashboard.friendlyId}
type="button"
onClick={() => !isAtLimit && setSelectedDashboardId(dashboard.friendlyId)}
disabled={isAtLimit}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition",
isAtLimit
? "cursor-not-allowed opacity-50"
: selectedDashboardId === dashboard.friendlyId
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
)}
>
<IconChartHistogram className="size-4 shrink-0 text-text-dimmed" />
<span className="flex-1 truncate">{dashboard.title}</span>
<span
className={cn(
"shrink-0 text-xs",
isAtLimit ? "text-error" : "text-text-dimmed"
)}
>
{dashboard.widgetCount}/{widgetLimit}
</span>
</button>
);
})}
</div>
</div>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
disabled={isLoading || !selectedDashboardId || isSelectedAtLimit}
>
{isLoading ? "Saving..." : "Save"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,64 @@
import * as Ariakit from "@ariakit/react";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { SelectItem, SelectPopover, SelectProvider } from "~/components/primitives/Select";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import type { QueryScope } from "~/services/queryService.server";
import { CubeTransparentIcon, GlobeAltIcon } from "@heroicons/react/20/solid";
import { IconListLetters } from "@tabler/icons-react";
const scopeOptions = [
{ value: "environment", label: "Environment" },
{ value: "project", label: "Project" },
{ value: "organization", label: "Organization" },
] as const;
export function ScopeFilter() {
const { value, replace } = useSearchParams();
const scope = (value("scope") as QueryScope) ?? "environment";
const handleChange = (newScope: string) => {
replace({ scope: newScope === "environment" ? undefined : newScope });
};
return (
<SelectProvider value={scope} setValue={handleChange}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Scope"
icon={<CubeTransparentIcon className="size-4" />}
value={<ScopeItem scope={scope} />}
removable={false}
variant="secondary/small"
/>
</Ariakit.Select>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ScopeItem scope={option.value} />
</SelectItem>
))}
</SelectPopover>
</SelectProvider>
);
}
function ScopeItem({ scope }: { scope: QueryScope }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
switch (scope) {
case "organization":
return `Org: ${organization.title}`;
case "project":
return `Project: ${project.name}`;
case "environment":
return <EnvironmentLabel environment={environment} />;
default:
return scope;
}
}
@@ -0,0 +1,125 @@
import { useState } from "react";
import { PencilIcon, TrashIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
} from "../primitives/Popover";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
export type TitleWidgetProps = {
title: string;
isDraggable?: boolean;
isResizing?: boolean;
/** Callback when rename is clicked. Receives the new title. */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked. */
onDelete?: () => void;
};
export function TitleWidget({
title,
isDraggable,
isResizing,
onRename,
onDelete,
}: TitleWidgetProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(title);
const hasMenu = onRename || onDelete;
return (
<div className="h-full">
<div
className={cn(
"group flex h-full items-center gap-2 rounded-lg border border-grid-bright bg-background-bright px-4",
isDraggable && "drag-handle cursor-grab active:cursor-grabbing"
)}
>
<span className="min-w-0 flex-1 truncate text-lg font-medium text-text-bright">
{title}
</span>
{hasMenu && (
<div className="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverVerticalEllipseTrigger isOpen={isMenuOpen} />
<PopoverContent align="end" className="p-0">
<div className="flex flex-col gap-1 p-1">
{onRename && (
<PopoverMenuItem
icon={PencilIcon}
title="Rename"
onClick={() => {
setRenameValue(title);
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
}}
/>
)}
{onDelete && (
<PopoverMenuItem
icon={TrashIcon}
title="Delete"
leadingIconClassName="text-error"
className="text-error hover:!bg-error/10"
onClick={() => {
onDelete();
setIsMenuOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* Rename Dialog */}
{onRename && (
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Rename title</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (renameValue.trim()) {
onRename(renameValue.trim());
setIsRenameDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Section title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -0,0 +1,255 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { motion } from "framer-motion";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/20/solid";
import { useEffect, useState } from "react";
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Feedback } from "~/components/Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "../primitives/Dialog";
import { FormButtons } from "../primitives/FormButtons";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { v3BillingPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
export function CreateDashboardButton({
organization,
project,
environment,
isCollapsed,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
isCollapsed: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
const limits = useDashboardLimits();
const plan = useCurrentPlan();
const isAtLimit = limits.used >= limits.limit;
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
// Close dialog when form submission starts (redirect is happening)
useEffect(() => {
if (navigation.formAction === formAction && navigation.state === "loading") {
setIsOpen(false);
}
}, [navigation.formAction, navigation.state, formAction]);
if (isCollapsed) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<button
type="button"
className="flex h-full w-full items-center justify-center rounded text-text-dimmed transition focus-custom hover:bg-charcoal-600 hover:text-text-bright"
>
<PlusIcon className="size-4" />
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
Create dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={limits}
canUpgrade={!!canUpgrade}
isFreePlan={plan?.v3Subscription?.isPaying === false}
organization={organization}
/>
) : (
<CreateDashboardDialog formAction={formAction} limits={limits} />
)}
</Dialog>
);
}
const PROGRESS_RING_R = 27.5;
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
const PROGRESS_COLOR_SUCCESS = "#28BF5C"; // mint-500 / success
const PROGRESS_COLOR_ERROR = "#E11D48"; // rose-600 / error
function CreateDashboardUpgradeDialog({
limits,
canUpgrade,
isFreePlan,
organization,
}: {
limits: { used: number; limit: number };
canUpgrade: boolean;
isFreePlan: boolean;
organization: MatchedOrganization;
}) {
if (isFreePlan) {
return (
<DialogContent>
<DialogHeader>Upgrade to unlock dashboards</DialogHeader>
<div className="flex items-center gap-4 pt-3">
<ArrowUpCircleIcon className="ml-1 size-14 shrink-0 text-indigo-500" />
<DialogDescription className="pt-0">
Custom metric dashboards are available on paid plans. Upgrade to create dashboards and
track your task metrics.
</DialogDescription>
</div>
<DialogFooter className="flex justify-between">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade plan
</LinkButton>
</DialogFooter>
</DialogContent>
);
}
const percentage = Math.min(limits.used / limits.limit, 1);
const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
return (
<DialogContent>
<DialogHeader>Dashboard limit reached</DialogHeader>
<div className="flex items-center gap-4 pt-3">
<div className="relative ml-1 mt-2 shrink-0" style={{ width: 60, height: 60 }}>
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="5"
r={PROGRESS_RING_R}
cx="30"
cy="30"
/>
<motion.circle
className="fill-none"
strokeWidth="5"
r={PROGRESS_RING_R}
cx="30"
cy="30"
strokeLinecap="round"
initial={{
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
stroke: PROGRESS_COLOR_SUCCESS,
}}
animate={{
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
stroke: PROGRESS_COLOR_ERROR,
}}
transition={{ duration: 1.2, ease: "easeInOut" }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-lg text-text-dimmed">
{limits.limit}
</span>
</div>
<DialogDescription className="pt-0">
{canUpgrade ? (
<>
{limits.limit === 1
? "Your plan includes 1 custom dashboard and it's already in use."
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
Upgrade your plan to create more.
</>
) : (
<>
{limits.limit === 1
? "Your plan includes 1 custom dashboard and it's already in use."
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
To create more, request a limit increase or visit the{" "}
<TextLink to={v3BillingPath(organization)}>billing page</TextLink> for pricing
details.
</>
)}
</DialogDescription>
</div>
<DialogFooter className="flex justify-between">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
{canUpgrade ? (
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade plan
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/medium">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
);
}
function CreateDashboardDialog({
formAction,
limits,
}: {
formAction: string;
limits: { used: number; limit: number };
}) {
const navigation = useNavigation();
const [title, setTitle] = useState("");
const isLoading = navigation.formAction === formAction;
return (
<DialogContent className="sm:max-w-sm">
<DialogHeader>Create dashboard</DialogHeader>
<Form method="post" action={formAction} className="space-y-4 pt-3">
<InputGroup>
<Label>Title</Label>
<Input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Dashboard"
required
/>
</InputGroup>
<Paragraph variant="extra-small" className="text-text-dimmed">
{limits.used}/{limits.limit} dashboards used
</Paragraph>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>
{isLoading ? "Creating..." : "Create"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
);
}
@@ -0,0 +1,123 @@
import { IconChartHistogram } from "@tabler/icons-react";
import { GripVerticalIcon, LineChartIcon } from "lucide-react";
import ReactGridLayout from "react-grid-layout";
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { SideMenuItem } from "./SideMenuItem";
import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors";
import { useReorderableList } from "./useReorderableList";
type SideMenuUser = Pick<UserWithDashboardPreferences, "dashboardPreferences"> & {
isImpersonating: boolean;
};
export function DashboardList({
organization,
project,
environment,
isCollapsed,
user,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
isCollapsed: boolean;
user: SideMenuUser;
}) {
const customDashboards = useCustomDashboards();
const initialOrder =
user.dashboardPreferences.sideMenu?.organizations?.[organization.id]?.orderedItems?.[
"customDashboards"
];
const {
orderedItems: orderedDashboards,
layout,
containerRef,
gridWidth,
canReorder,
handleDrag,
handleDragStop,
getIsLast,
} = useReorderableList({
organizationId: organization.id,
listId: "customDashboards",
items: customDashboards,
itemKey: (d) => d.friendlyId,
initialOrder,
isImpersonating: user.isImpersonating,
});
return (
<div ref={containerRef}>
{canReorder ? (
<ReactGridLayout
layout={layout}
width={gridWidth}
gridConfig={{
cols: 1,
rowHeight: 32,
margin: [0, 0] as const,
containerPadding: [0, 0] as const,
}}
resizeConfig={{ enabled: false }}
dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }}
onDrag={handleDrag}
onDragStop={handleDragStop}
className="sidebar-reorder-grid"
autoSize
>
{orderedDashboards.map((dashboard, index) => {
const isLast = getIsLast(dashboard.friendlyId, index);
return (
<div key={dashboard.friendlyId}>
<SideMenuItem
name={dashboard.title}
icon={
isCollapsed
? IconChartHistogram
: isLast
? TreeConnectorEnd
: TreeConnectorBranch
}
activeIconColor={isCollapsed ? "text-customDashboards" : undefined}
inactiveIconColor={isCollapsed ? "text-customDashboards" : undefined}
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
isCollapsed={isCollapsed}
action={
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
<GripVerticalIcon className="size-3.5" />
</div>
}
/>
</div>
);
})}
</ReactGridLayout>
) : (
orderedDashboards.map((dashboard, index) => {
const isLast = index === orderedDashboards.length - 1;
return (
<SideMenuItem
key={dashboard.friendlyId}
name={dashboard.title}
icon={
isCollapsed
? LineChartIcon
: isLast
? TreeConnectorEnd
: TreeConnectorBranch
}
activeIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
inactiveIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
isCollapsed={isCollapsed}
/>
);
})
)}
</div>
);
}
+383 -308
View File
@@ -20,11 +20,11 @@ import {
ServerStackIcon,
Squares2X2Icon,
TableCellsIcon,
UsersIcon
UsersIcon,
} from "@heroicons/react/20/solid";
import { Link, useFetcher, useNavigation } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import simplur from "simplur";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
@@ -40,9 +40,8 @@ import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useHasAdminAccess } from "~/hooks/useUser";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { useHasAdminAccess } from "~/hooks/useUser";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
@@ -65,6 +64,7 @@ import {
v3ApiKeysPath,
v3BatchesPath,
v3BillingPath,
v3BuiltInDashboardPath,
v3BulkActionsPath,
v3DeploymentsPath,
v3EnvironmentPath,
@@ -88,23 +88,39 @@ import { ImpersonationBanner } from "../ImpersonationBanner";
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
import { Dialog, DialogTrigger } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverTrigger
} from "../primitives/Popover";
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { TextLink } from "../primitives/TextLink";
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../primitives/Tooltip";
import { ShortcutsAutoOpen } from "../Shortcuts";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { CreateDashboardButton } from "./DashboardDialogs";
import { DashboardList } from "./DashboardList";
import { EnvironmentSelector } from "./EnvironmentSelector";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import { type SideMenuSectionId } from "./sideMenuTypes";
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
/** Get the collapsed state for a specific side menu section from user preferences */
function getSectionCollapsed(
sideMenu: { collapsedSections?: Record<string, boolean> } | undefined,
sectionId: SideMenuSectionId
): boolean {
return sideMenu?.collapsedSections?.[sectionId] ?? false;
}
type SideMenuUser = Pick<
UserWithDashboardPreferences,
"email" | "admin" | "dashboardPreferences"
> & {
isImpersonating: boolean;
};
export type SideMenuProject = Pick<
@@ -138,7 +154,8 @@ export function SideMenu({
const preferencesFetcher = useFetcher();
const pendingPreferencesRef = useRef<{
isCollapsed?: boolean;
manageSectionCollapsed?: boolean;
sectionId?: SideMenuSectionId;
sectionCollapsed?: boolean;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentPlan = useCurrentPlan();
@@ -149,7 +166,11 @@ export function SideMenu({
const featureFlags = useFeatureFlags();
const persistSideMenuPreferences = useCallback(
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
(data: {
isCollapsed?: boolean;
sectionId?: SideMenuSectionId;
sectionCollapsed?: boolean;
}) => {
if (user.isImpersonating) return;
// Merge with any pending changes
@@ -170,8 +191,9 @@ export function SideMenu({
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
formData.append("sectionId", pending.sectionId);
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
@@ -191,13 +213,18 @@ export function SideMenu({
}
if (user.isImpersonating) return;
const pending = pendingPreferencesRef.current;
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
const hasPendingChanges =
pending.isCollapsed !== undefined ||
(pending.sectionId !== undefined && pending.sectionCollapsed !== undefined);
if (hasPendingChanges) {
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
formData.append("sectionId", pending.sectionId);
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
@@ -214,9 +241,10 @@ export function SideMenu({
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
};
const handleManageSectionToggle = useCallback(
(collapsed: boolean) => {
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
/** Generic handler for any collapsible section - just pass the section ID */
const handleSectionToggle = useCallback(
(sectionId: SideMenuSectionId) => (collapsed: boolean) => {
persistSideMenuPreferences({ sectionId, sectionCollapsed: collapsed });
},
[persistSideMenuPreferences]
);
@@ -255,294 +283,340 @@ export function SideMenu({
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
)}
>
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
<ProjectSelector
organizations={organizations}
organization={organization}
project={project}
user={user}
isCollapsed={isCollapsed}
/>
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
<ProjectSelector
organizations={organizations}
organization={organization}
project={project}
user={user}
isCollapsed={isCollapsed}
/>
</div>
{isAdmin && !user.isImpersonating ? (
<CollapsibleElement isCollapsed={isCollapsed}>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton
variant="minimal/medium"
to={adminPath()}
TrailingIcon={UsersIcon}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CollapsibleElement>
) : isAdmin && user.isImpersonating ? (
<CollapsibleElement isCollapsed={isCollapsed}>
<ImpersonationBanner />
</CollapsibleElement>
) : null}
</div>
{isAdmin && !user.isImpersonating ? (
<CollapsibleElement isCollapsed={isCollapsed}>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CollapsibleElement>
) : isAdmin && user.isImpersonating ? (
<CollapsibleElement isCollapsed={isCollapsed}>
<ImpersonationBanner />
</CollapsibleElement>
) : null}
</div>
<div
className={cn(
"min-h-0 overflow-y-auto pt-2",
isCollapsed
? "scrollbar-none"
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
ref={borderRef}
>
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
<div className="w-full space-y-1">
<SideMenuHeader title={"Environment"} isCollapsed={isCollapsed} collapsedTitle="Env" />
<div className="flex items-center">
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-full"
<div
className={cn(
"min-h-0 overflow-y-auto pt-2",
isCollapsed
? "scrollbar-none"
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
ref={borderRef}
>
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
<div className="w-full space-y-1">
<SideMenuHeader
title={"Environment"}
isCollapsed={isCollapsed}
collapsedTitle="Env"
/>
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<CollapsibleElement isCollapsed={isCollapsed}>
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
</CollapsibleElement>
)}
<div className="flex items-center">
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-full"
isCollapsed={isCollapsed}
/>
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<CollapsibleElement isCollapsed={isCollapsed}>
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
</CollapsibleElement>
)}
</div>
</div>
</div>
<div className="w-full">
<SideMenuItem
name="Tasks"
icon={TaskIconSmall}
activeIconColor="text-tasks"
inactiveIconColor="text-tasks"
to={v3EnvironmentPath(organization, project, environment)}
data-action="tasks"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Runs"
icon={RunsIconExtraSmall}
activeIconColor="text-runs"
inactiveIconColor="text-runs"
to={v3RunsPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-batches"
inactiveIconColor="text-batches"
to={v3BatchesPath(organization, project, environment)}
data-action="batches"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
activeIconColor="text-schedules"
inactiveIconColor="text-schedules"
to={v3SchedulesPath(organization, project, environment)}
data-action="schedules"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Queues"
icon={RectangleStackIcon}
activeIconColor="text-queues"
inactiveIconColor="text-queues"
to={v3QueuesPath(organization, project, environment)}
data-action="queues"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Waitpoint tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
inactiveIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
activeIconColor="text-deployments"
inactiveIconColor="text-deployments"
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<div className="w-full">
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-logs"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
name="Tasks"
icon={TaskIconSmall}
activeIconColor="text-tasks"
inactiveIconColor="text-tasks"
to={v3EnvironmentPath(organization, project, environment)}
data-action="tasks"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-tests"
inactiveIconColor="text-tests"
to={v3TestPath(organization, project, environment)}
data-action="test"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Runs"
icon={RunsIconExtraSmall}
activeIconColor="text-runs"
inactiveIconColor="text-runs"
to={v3RunsPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-batches"
inactiveIconColor="text-batches"
to={v3BatchesPath(organization, project, environment)}
data-action="batches"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
activeIconColor="text-schedules"
inactiveIconColor="text-schedules"
to={v3SchedulesPath(organization, project, environment)}
data-action="schedules"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Queues"
icon={RectangleStackIcon}
activeIconColor="text-queues"
inactiveIconColor="text-queues"
to={v3QueuesPath(organization, project, environment)}
data-action="queues"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Waitpoint tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
inactiveIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
activeIconColor="text-deployments"
inactiveIconColor="text-deployments"
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-logs"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-tests"
inactiveIconColor="text-tests"
to={v3TestPath(organization, project, environment)}
data-action="test"
isCollapsed={isCollapsed}
/>
</div>
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
<SideMenuItem
name="Query"
icon={TableCellsIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={queryPath(organization, project, environment)}
data-action="query"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
<SideMenuSection
title="Insights"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"metrics"
)}
onCollapseToggle={handleSectionToggle("metrics")}
>
<SideMenuItem
name="Query"
icon={TableCellsIcon}
activeIconColor="text-query"
inactiveIconColor="text-query"
to={queryPath(organization, project, environment)}
data-action="query"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Metrics"
icon={ChartBarIcon}
activeIconColor="text-metrics"
inactiveIconColor="text-metrics"
to={v3BuiltInDashboardPath(organization, project, environment, "overview")}
data-action="metrics-overview"
isCollapsed={isCollapsed}
action={
<CreateDashboardButton
organization={organization}
project={project}
environment={environment}
isCollapsed={isCollapsed}
/>
}
/>
<DashboardList
organization={organization}
project={project}
environment={environment}
isCollapsed={isCollapsed}
user={user}
/>
</SideMenuSection>
)}
</div>
<SideMenuSection
title="Manage"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false}
onCollapseToggle={handleManageSectionToggle}
>
<SideMenuItem
name="Bulk actions"
icon={ListCheckedIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3BulkActionsPath(organization, project, environment)}
data-action="bulk actions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ApiKeysPath(organization, project, environment)}
data-action="api keys"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Environment variables"
icon={IdentificationIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectAlertsPath(organization, project, environment)}
data-action="alerts"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Preview branches"
icon={BranchEnvironmentIconSmall}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={branchesPath(organization, project, environment)}
data-action="preview-branches"
isCollapsed={isCollapsed}
/>
{isManagedCloud && (
<SideMenuSection
title="Manage"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "manage")}
onCollapseToggle={handleSectionToggle("manage")}
>
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
name="Bulk actions"
icon={ListCheckedIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={concurrencyPath(organization, project, environment)}
data-action="concurrency"
to={v3BulkActionsPath(organization, project, environment)}
data-action="bulk actions"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Regions"
icon={GlobeAmericasIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={regionsPath(organization, project, environment)}
data-action="regions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Limits"
icon={AdjustmentsHorizontalIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={limitsPath(organization, project, environment)}
data-action="limits"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Project settings"
icon={Cog8ToothIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
</div>
</div>
<div>
<IncidentStatusPanel isCollapsed={isCollapsed} />
<motion.div
layout
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn("flex flex-col gap-1 border-t border-grid-bright p-1", isCollapsed && "items-center")}
>
<HelpAndAI isCollapsed={isCollapsed} />
{isFreeUser && (
<CollapsibleHeight isCollapsed={isCollapsed}>
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
<SideMenuItem
name="API keys"
icon={KeyIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ApiKeysPath(organization, project, environment)}
data-action="api keys"
isCollapsed={isCollapsed}
/>
</CollapsibleHeight>
)}
</motion.div>
</div>
<SideMenuItem
name="Environment variables"
icon={IdentificationIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectAlertsPath(organization, project, environment)}
data-action="alerts"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Preview branches"
icon={BranchEnvironmentIconSmall}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={branchesPath(organization, project, environment)}
data-action="preview-branches"
isCollapsed={isCollapsed}
/>
{isManagedCloud && (
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={concurrencyPath(organization, project, environment)}
data-action="concurrency"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Regions"
icon={GlobeAmericasIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={regionsPath(organization, project, environment)}
data-action="regions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Limits"
icon={AdjustmentsHorizontalIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={limitsPath(organization, project, environment)}
data-action="limits"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Project settings"
icon={Cog8ToothIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
</div>
</div>
<div>
<IncidentStatusPanel isCollapsed={isCollapsed} />
<motion.div
layout
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn(
"flex flex-col gap-1 border-t border-grid-bright p-1",
isCollapsed && "items-center"
)}
>
<HelpAndAI isCollapsed={isCollapsed} />
{isFreeUser && (
<CollapsibleHeight isCollapsed={isCollapsed}>
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
</CollapsibleHeight>
)}
</motion.div>
</div>
</div>
</div>
);
@@ -890,7 +964,12 @@ function CollapsibleHeight({
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
return (
<LayoutGroup>
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
<div
className={cn(
"flex w-full",
isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between"
)}
>
<ShortcutsAutoOpen />
<HelpAndFeedback isCollapsed={isCollapsed} />
<AskAI isCollapsed={isCollapsed} />
@@ -909,7 +988,7 @@ function AnimatedChevron({
// When hovering and expanded: left chevron (pointing left to collapse)
// When hovering and collapsed: right chevron (pointing right to expand)
// When not hovering: straight vertical line
const getRotation = () => {
if (!isHovering) return { top: 0, bottom: 0 };
if (isCollapsed) {
@@ -922,7 +1001,7 @@ function AnimatedChevron({
};
const { top, bottom } = getRotation();
// Calculate horizontal offset to keep chevron centered when rotated
// Left chevron: translate left (-1.5px)
// Right chevron: translate right (+1.5px)
@@ -938,7 +1017,7 @@ function AnimatedChevron({
viewBox="0 0 4 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 group-hover:text-text-bright transition-colors"
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 transition-colors group-hover:text-text-bright"
initial={false}
animate={{
x: getTranslateX(),
@@ -981,22 +1060,18 @@ function AnimatedChevron({
);
}
function CollapseToggle({
isCollapsed,
onToggle,
}: {
isCollapsed: boolean;
onToggle: () => void;
}) {
function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) {
const [isHovering, setIsHovering] = useState(false);
return (
<div className="absolute -right-3 top-1/2 z-10 -translate-y-1/2">
{/* Vertical line to mask the side menu border */}
<div className={cn(
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
isHovering ? "bg-charcoal-750" : "bg-background-bright"
)} />
<div
className={cn(
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
isHovering ? "bg-charcoal-750" : "bg-background-bright"
)}
/>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
@@ -17,6 +17,7 @@ export function SideMenuItem({
badge,
target,
isCollapsed = false,
action,
}: {
icon?: RenderIcon;
activeIconColor?: string;
@@ -28,59 +29,84 @@ export function SideMenuItem({
badge?: ReactNode;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
isCollapsed?: boolean;
action?: ReactNode;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<SimpleTooltip
button={
<Link
to={to}
target={target}
className={cn(
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750",
isActive ? "bg-tertiary" : ""
)}
>
<Icon
icon={icon}
className={cn(
"size-5 shrink-0",
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
)}
/>
const link = (
<Link
to={to}
target={target}
className={cn(
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750 group-hover/menuitem:bg-charcoal-750",
isActive ? "bg-tertiary" : ""
)}
>
<Icon
icon={icon}
className={cn(
"size-5 shrink-0",
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
)}
/>
<motion.div
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
initial={false}
animate={{
width: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<span className="truncate select-none text-2sm">{name}</span>
{badge && !isCollapsed && (
<motion.div
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
className="ml-1 flex shrink-0 items-center gap-1"
initial={false}
animate={{
width: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
opacity: 1,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<span className="truncate text-2sm">{name}</span>
{badge && !isCollapsed && (
<motion.div
className="ml-1 flex shrink-0 items-center gap-1"
initial={false}
animate={{
opacity: 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{badge}
</motion.div>
)}
{trailingIcon && !isCollapsed && (
<Icon
icon={trailingIcon}
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
/>
)}
{badge}
</motion.div>
</Link>
}
)}
{trailingIcon && !isCollapsed && (
<Icon
icon={trailingIcon}
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
/>
)}
</motion.div>
</Link>
);
if (action) {
return (
<div className="group/menuitem relative h-8 w-full">
<SimpleTooltip
button={link}
content={name}
side="right"
sideOffset={8}
buttonClassName="!h-8 block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
{!isCollapsed && (
<div className="absolute top-1 right-1 bottom-1 flex aspect-square items-center justify-center rounded group-hover/menuitem:bg-charcoal-750">
{action}
</div>
)}
</div>
);
}
return (
<SimpleTooltip
button={link}
content={name}
side="right"
sideOffset={8}
@@ -10,6 +10,8 @@ type Props = {
/** When true, hides the section header and shows only children */
isSideMenuCollapsed?: boolean;
itemSpacingClassName?: string;
/** Optional action element (e.g., + button) to render on the right side of the header */
headerAction?: React.ReactNode;
};
/** A collapsible section for the side menu
@@ -22,6 +24,7 @@ export function SideMenuSection({
children,
isSideMenuCollapsed = false,
itemSpacingClassName = "space-y-px",
headerAction,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
@@ -37,23 +40,26 @@ export function SideMenuSection({
<div className="relative w-full">
{/* Header - fades out when sidebar is collapsed */}
<motion.div
className="flex cursor-pointer items-center gap-1 overflow-hidden rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-charcoal-750"
initial={false}
animate={{
opacity: isSideMenuCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
>
<h2 className="text-xs whitespace-nowrap">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.div>
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
<h2 className="whitespace-nowrap text-xs">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.div>
</div>
{headerAction && <div className="flex items-center">{headerAction}</div>}
</motion.div>
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
<motion.div
@@ -0,0 +1,29 @@
import { cn } from "~/utils/cn";
// Tree connector icons for sub-items. The SVG viewBox is 20x20 matching the size-5 icon area.
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
export function TreeConnectorBranch({ className }: { className?: string }) {
return (
<svg
className={cn("overflow-visible", className, "text-charcoal-600")}
viewBox="0 0 20 20"
fill="none"
>
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
</svg>
);
}
export function TreeConnectorEnd({ className }: { className?: string }) {
return (
<svg
className={cn("overflow-visible", className, "text-charcoal-600")}
viewBox="0 0 20 20"
fill="none"
>
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
</svg>
);
}
@@ -0,0 +1,7 @@
import { z } from "zod";
// Valid section IDs that can have their collapsed state toggled
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
// Inferred type from the schema
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
@@ -0,0 +1,129 @@
import { useFetcher } from "@remix-run/react";
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
import { type Layout, useContainerWidth } from "react-grid-layout";
/**
* Generic hook for managing a reorderable list in the side menu.
*
* Handles order state, sorting, grid layout, drag callbacks, and persistence
* via the `/resources/preferences/sidemenu` resource route.
*
* @param organizationId - Organization ID for scoping the persisted order
* @param listId - Identifier for this list (e.g. "customDashboards")
* @param items - The items to reorder
* @param itemKey - Extract a stable string key from each item
* @param initialOrder - Initial order from stored preferences (if any)
* @param isImpersonating - Skip persistence when impersonating
*/
export function useReorderableList<T>({
organizationId,
listId,
items,
itemKey,
initialOrder,
isImpersonating,
}: {
organizationId: string;
listId: string;
items: T[];
itemKey: (item: T) => string;
initialOrder: string[] | undefined;
isImpersonating: boolean;
}) {
const orderFetcher = useFetcher();
const [order, setOrder] = useState<string[]>(
() => initialOrder ?? items.map(itemKey)
);
// Sync order when organizationId changes (component may not remount)
useEffect(() => {
setOrder(initialOrder ?? items.map(itemKey));
}, [organizationId]);
// Sort items by stored order, new items go to end
const orderedItems = useMemo(() => {
const orderMap = new Map(order.map((id, i) => [id, i]));
return [...items].sort((a, b) => {
const aIdx = orderMap.get(itemKey(a)) ?? Infinity;
const bIdx = orderMap.get(itemKey(b)) ?? Infinity;
return aIdx - bIdx;
});
}, [items, order, itemKey]);
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
const layout = useMemo(
() =>
orderedItems.map((item, i) => ({
i: itemKey(item),
x: 0,
y: i,
w: 1,
h: 1,
})),
[orderedItems, itemKey]
);
// Width measurement for ReactGridLayout
const {
width: gridWidth,
containerRef,
mounted: gridMounted,
} = useContainerWidth({ initialWidth: 216 });
const canReorder = orderedItems.length >= 2;
// Track layout during drag for real-time visual updates
const [dragLayout, setDragLayout] = useState<Layout | null>(null);
const handleDrag = useCallback((layout: Layout) => {
setDragLayout(layout);
}, []);
// Handle drag stop - extract new order from layout y-positions
const handleDragStop = useCallback(
(layout: Layout) => {
setDragLayout(null);
const sorted = [...layout].sort((a, b) => a.y - b.y);
const newOrder = sorted.map((item) => item.i);
if (JSON.stringify(newOrder) === JSON.stringify(order)) return;
setOrder(newOrder);
// Persist immediately
if (!isImpersonating) {
const formData = new FormData();
formData.append("organizationId", organizationId);
formData.append("listId", listId);
formData.append("itemOrder", JSON.stringify(newOrder));
orderFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
}
},
[order, organizationId, listId, isImpersonating, orderFetcher]
);
// Compute which item is visually last (during drag or at rest)
const getIsLast = useCallback(
(key: string, index: number) => {
if (dragLayout) {
const maxY = Math.max(...dragLayout.map((l) => l.y));
return dragLayout.find((l) => l.i === key)?.y === maxY;
}
return index === orderedItems.length - 1;
},
[dragLayout, orderedItems.length]
);
return {
orderedItems,
layout,
containerRef: containerRef as Ref<HTMLDivElement>,
gridWidth,
gridMounted,
canReorder,
handleDrag,
handleDragStop,
getIsLast,
};
}
@@ -27,6 +27,7 @@ type AppliedFilterProps = {
onRemove?: () => void;
variant?: Variant;
className?: string;
valueClassName?: string;
};
export function AppliedFilter({
@@ -37,6 +38,7 @@ export function AppliedFilter({
onRemove,
variant = "secondary/small",
className,
valueClassName,
}: AppliedFilterProps) {
const variantClassName = variants[variant];
return (
@@ -48,14 +50,18 @@ export function AppliedFilter({
className
)}
>
<div className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}>
<div
className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}
>
<div className="-mt-[0.5px] flex items-center gap-1">
{icon}
{label && <div className="text-text-bright">
<span>{label}</span>:
</div>}
{label && (
<div className="text-text-bright">
<span>{label}</span>:
</div>
)}
</div>
<div className="text-text-dimmed">
<div className={cn("text-text-dimmed", valueClassName)}>
<div>{value}</div>
</div>
</div>
@@ -190,7 +190,8 @@ const ClientTabsContent = React.forwardRef<
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
className,
"data-[state=inactive]:hidden"
)}
{...props}
/>
@@ -7,7 +7,7 @@ export function FormButtons({
className,
}: {
cancelButton?: React.ReactNode;
confirmButton: React.ReactNode;
confirmButton?: React.ReactNode;
defaultAction?: { name: string; value: string; disabled?: boolean };
className?: string;
}) {
@@ -29,7 +29,7 @@ export function FormButtons({
aria-hidden="true"
/>
)}
{cancelButton ? cancelButton : <div />} {confirmButton}
{cancelButton ? cancelButton : <div />} {confirmButton ?? null}
</div>
);
}
@@ -1,13 +1,15 @@
import { AnimatePresence, useAnimate, usePresence } from "framer-motion";
import { useEffect } from "react";
import { cn } from "~/utils/cn";
type LoadingBarDividerProps = {
isLoading: boolean;
className?: string;
};
export function LoadingBarDivider({ isLoading }: LoadingBarDividerProps) {
export function LoadingBarDivider({ isLoading, className }: LoadingBarDividerProps) {
return (
<div className="relative h-px w-full overflow-hidden bg-grid-bright">
<div className={cn("relative h-px w-full overflow-hidden bg-grid-bright", className)}>
<AnimationDivider isLoading={isLoading} />
</div>
);
@@ -243,20 +243,41 @@ function PopoverArrowTrigger({
);
}
const popoverVerticalEllipseVariants = {
minimal: {
trigger:
"size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
icon: "size-5",
},
secondary: {
trigger:
"size-6 rounded border border-charcoal-600 bg-secondary text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550",
icon: "size-4",
},
} as const;
type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants;
function PopoverVerticalEllipseTrigger({
isOpen,
variant = "minimal",
className,
...props
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
}: {
isOpen?: boolean;
variant?: PopoverVerticalEllipseVariant;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const styles = popoverVerticalEllipseVariants[variant];
return (
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded-[3px] p-0.5 text-text-dimmed transition focus-custom hover:bg-tertiary hover:text-text-bright",
"group flex items-center justify-center transition focus-custom",
styles.trigger,
className
)}
>
<EllipsisVerticalIcon className={cn("size-5 transition group-hover:text-text-bright")} />
<EllipsisVerticalIcon className={cn(styles.icon, "transition")} />
</PopoverTrigger>
);
}
@@ -33,7 +33,7 @@ const ResizableHandle = ({
// Vertical orientation
"data-[handle-orientation=vertical]:h-0.75 data-[handle-orientation=vertical]:w-full",
"data-[handle-orientation=vertical]:after:inset-x-0 data-[handle-orientation=vertical]:after:inset-y-auto",
"data-[handle-orientation=vertical]:after:top-1/2 data-[handle-orientation=vertical]:after:left-0",
"data-[handle-orientation=vertical]:after:left-0 data-[handle-orientation=vertical]:after:top-1/2",
"data-[handle-orientation=vertical]:after:h-1 data-[handle-orientation=vertical]:after:w-full",
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
className
@@ -42,9 +42,9 @@ const ResizableHandle = ({
{...props}
>
{/* Horizontal orientation line indicator */}
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:hidden" />
<div className="absolute left-[0.0625rem] top-0 z-20 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:hidden" />
{/* Vertical orientation line indicator */}
<div className="absolute left-0 top-[0.0625rem] hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:block" />
<div className="absolute left-0 top-[0.0625rem] z-20 hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:block" />
{withHandle && (
<>
{/* Horizontal orientation dots (vertical arrangement) */}
@@ -87,7 +87,7 @@ function SimpleTooltip({
<TooltipTrigger
type="button"
tabIndex={-1}
className={cn("h-fit", buttonClassName)}
className={cn(!asChild && "h-fit", buttonClassName)}
style={buttonStyle}
asChild={asChild}
>
@@ -1,46 +0,0 @@
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import { Spinner } from "../Spinner";
interface BigNumberProps {
animate?: boolean;
loading?: boolean;
value?: number;
valueClassName?: string;
defaultValue?: number;
suffix?: string;
suffixClassName?: string;
}
export function BigNumber({
value,
defaultValue,
valueClassName,
suffix,
suffixClassName,
animate = false,
loading = false,
}: BigNumberProps) {
const v = value ?? defaultValue;
return (
<div
className={cn(
"h-full text-[3.75rem] font-normal tabular-nums leading-none text-text-bright",
valueClassName
)}
>
{loading ? (
<div className="grid h-full place-items-center">
<Spinner className="size-6" />
</div>
) : v !== undefined ? (
<div className="flex items-baseline gap-1">
{animate ? <AnimatedNumber value={v} /> : v}
{suffix && <div className={cn("text-xs", suffixClassName)}>{suffix}</div>}
</div>
) : (
""
)}
</div>
);
}
@@ -0,0 +1,171 @@
import type { OutputColumnMetadata } from "@internal/tsql";
import { useMemo } from "react";
import type {
BigNumberAggregationType,
BigNumberConfiguration,
} from "~/components/metrics/QueryWidget";
import { AnimatedNumber } from "../AnimatedNumber";
import { Spinner } from "../Spinner";
import { Paragraph } from "../Paragraph";
interface BigNumberCardProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: BigNumberConfiguration;
isLoading?: boolean;
}
/**
* Extracts numeric values from a specific column across all rows,
* optionally sorting them first.
*/
function extractColumnValues(
rows: Record<string, unknown>[],
column: string,
sortDirection?: "asc" | "desc"
): number[] {
const values: number[] = [];
const sortedRows = sortDirection
? [...rows].sort((a, b) => {
const aVal = toNumber(a[column]);
const bVal = toNumber(b[column]);
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
})
: rows;
for (const row of sortedRows) {
const val = row[column];
if (typeof val === "number") {
values.push(val);
} else if (typeof val === "string") {
const parsed = parseFloat(val);
if (!isNaN(parsed)) {
values.push(parsed);
}
}
}
return values;
}
function toNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: BigNumberAggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
case "first":
return values[0];
case "last":
return values[values.length - 1];
}
}
/**
* Computes the display value and unit suffix for abbreviated display.
* Returns the divided-down number (e.g. 1.5 for 1500) and the suffix (e.g. "K"),
* along with the appropriate decimal places for formatting.
*/
function abbreviateValue(value: number): {
displayValue: number;
unitSuffix?: string;
decimalPlaces: number;
} {
if (Math.abs(value) >= 1_000_000_000) {
const v = value / 1_000_000_000;
return { displayValue: v, unitSuffix: "B", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
if (Math.abs(value) >= 1_000_000) {
const v = value / 1_000_000;
return { displayValue: v, unitSuffix: "M", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
if (Math.abs(value) >= 1_000) {
const v = value / 1_000;
return { displayValue: v, unitSuffix: "K", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
return { displayValue: value, decimalPlaces: getDecimalPlaces(value) };
}
/**
* Determines decimal places for plain (non-abbreviated) display.
*/
function getDecimalPlaces(value: number): number {
if (Number.isInteger(value)) return 0;
const abs = Math.abs(value);
if (abs >= 100) return 0;
if (abs >= 10) return 1;
if (abs >= 1) return 2;
if (abs >= 0.01) return 3;
return 4;
}
export function BigNumberCard({ rows, columns, config, isLoading = false }: BigNumberCardProps) {
const { column, aggregation, sortDirection, abbreviate = true, prefix, suffix } = config;
const result = useMemo(() => {
if (rows.length === 0) return null;
const values = extractColumnValues(rows, column, sortDirection);
if (values.length === 0) return null;
return aggregateValues(values, aggregation);
}, [rows, column, aggregation, sortDirection]);
if (isLoading) {
return (
<div className="grid h-full place-items-center [container-type:size]">
<Spinner className="size-6" />
</div>
);
}
if (result === null) {
return (
<div className="grid h-full place-items-center [container-type:size]">
<Paragraph variant="small" className="text-text-dimmed">
No data to display
</Paragraph>
</div>
);
}
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
? abbreviateValue(result)
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
return (
<div className="h-full w-full [container-type:size]">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap font-normal tabular-nums leading-none text-text-bright text-[clamp(24px,12cqw,96px)]">
{prefix && <span>{prefix}</span>}
<AnimatedNumber value={displayValue} decimalPlaces={decimalPlaces} />
{(unitSuffix || suffix) && (
<span className="text-[0.4em] text-text-dimmed">
{unitSuffix}
{unitSuffix && suffix ? " " : ""}
{suffix}
</span>
)}
</div>
</div>
</div>
);
}
@@ -15,9 +15,22 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
);
};
const CardHeader = ({ children }: { children: ReactNode }) => {
const CardHeader = ({
children,
draggable,
}: {
children: ReactNode;
draggable?: boolean;
}) => {
return (
<Header3 className="mb-3 flex items-center justify-between gap-2 px-3">{children}</Header3>
<Header3
className={cn(
"drag-handle mb-3 flex items-center justify-between gap-2 px-3",
draggable && "cursor-grab active:cursor-grabbing"
)}
>
{children}
</Header3>
);
};
@@ -162,26 +162,27 @@ export function ChartBarRenderer({
domain={["auto", (dataMax: number) => dataMax * 1.15]}
{...yAxisPropsProp}
/>
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
{!showLegend && (
<ChartTooltip
cursor={{ fill: "#2C3034" }}
content={
tooltipLabelFormatter ? (
<ChartTooltipContent />
) : (
<ZoomTooltip
isSelecting={zoom?.isSelecting}
refAreaLeft={zoom?.refAreaLeft}
refAreaRight={zoom?.refAreaRight}
invalidSelection={zoom?.invalidSelection}
/>
)
}
labelFormatter={tooltipLabelFormatter}
allowEscapeViewBox={{ x: false, y: true }}
/>
)}
{/* When legend is shown below the chart, render tooltip with cursor only (no content popup).
Otherwise render the full tooltip with zoom instructions. */}
<ChartTooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={
showLegend ? (
() => null
) : tooltipLabelFormatter ? (
<ChartTooltipContent />
) : (
<ZoomTooltip
isSelecting={zoom?.isSelecting}
refAreaLeft={zoom?.refAreaLeft}
refAreaRight={zoom?.refAreaRight}
invalidSelection={zoom?.invalidSelection}
/>
)
}
labelFormatter={tooltipLabelFormatter}
allowEscapeViewBox={{ x: false, y: true }}
/>
{/* Zoom selection area - rendered before bars to appear behind them */}
{enableZoom && zoom?.refAreaLeft !== null && zoom?.refAreaRight !== null && (
@@ -1,8 +1,6 @@
import React, { useMemo } from "react";
import { useChartContext } from "./ChartContext";
import { useSeriesTotal } from "./ChartRoot";
import { Button } from "../Buttons";
import { Paragraph } from "../Paragraph";
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
@@ -132,11 +130,7 @@ export function ChartLegendCompound({
return (
<div
className={cn(
"flex flex-col pt-4 text-sm",
scrollable && "max-h-[50%] min-h-0",
className
)}
className={cn("flex flex-col pt-4 text-sm", scrollable && "max-h-[50%] min-h-0", className)}
>
{/* Total row */}
<div
@@ -155,7 +149,13 @@ export function ChartLegendCompound({
<div className="mx-2 my-1 shrink-0 border-t border-charcoal-750" />
{/* Legend items - scrollable when scrollable prop is true */}
<div className={cn("flex flex-col", scrollable && "min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600")}>
<div
className={cn(
"flex flex-col",
scrollable &&
"min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
>
{legendItems.visible.map((item) => {
const total = currentData[item.dataKey] ?? 0;
const isActive = highlight.activeBarKey === item.dataKey;
@@ -211,7 +211,10 @@ export function ChartLegendCompound({
remainingCount={legendItems.remaining - 1}
/>
) : (
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
<ViewAllDataRow
remainingCount={legendItems.remaining}
onViewAll={onViewAllLegendItems}
/>
))}
</div>
</div>
@@ -225,23 +228,26 @@ type ViewAllDataRowProps = {
function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) {
return (
<Button
variant="minimal/small"
fullWidth
iconSpacing="justify-between"
className="px-2 py-1"
<div
className="relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition hover:bg-charcoal-850"
onClick={onViewAll}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onViewAll?.();
}
}}
>
<div className="flex items-center gap-1.5 text-text-dimmed">
<div className="h-3 w-1 rounded-[2px] border border-charcoal-600" />
<Paragraph variant="extra-small" className="tabular-nums">
{remainingCount} more
</Paragraph>
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<div className="w-1 shrink-0 self-stretch rounded-[2px] border border-charcoal-600" />
<span className="text-text-dimmed tabular-nums">{remainingCount} more</span>
</div>
<span className="self-start text-indigo-500">View all</span>
</div>
<Paragraph variant="extra-small" className="text-indigo-500">
View all
</Paragraph>
</Button>
</div>
);
}
@@ -261,11 +267,11 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-2">
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{item.color && (
<div
className="h-3 w-1 shrink-0 rounded-[2px]"
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
@@ -155,14 +155,12 @@ export function ChartLineRenderer({
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
<XAxis {...xAxisConfig} />
<YAxis {...yAxisConfig} />
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
{!showLegend && (
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={tooltipLabelFormatter}
/>
)}
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={showLegend ? () => null : <ChartTooltipContent indicator="line" />}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
{dataKeys.map((key) => (
<Area
@@ -205,14 +203,12 @@ export function ChartLineRenderer({
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
<XAxis {...xAxisConfig} />
<YAxis {...yAxisConfig} />
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
{!showLegend && (
<ChartTooltip
cursor={false}
content={<ChartTooltipContent />}
labelFormatter={tooltipLabelFormatter}
/>
)}
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={showLegend ? () => null : <ChartTooltipContent />}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
{dataKeys.map((key) => (
<Line
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ import {
startOfMonth,
startOfWeek,
subDays,
subWeeks
subWeeks,
} from "date-fns";
import parse from "parse-duration";
import { startTransition, useCallback, useEffect, useState, type ReactNode } from "react";
@@ -105,7 +105,7 @@ const timePeriods = [
{
label: "30 days",
value: "30d",
}
},
];
const timeUnits = [
@@ -214,6 +214,44 @@ export const timeFilters = ({
};
};
export function timeFilterFromTo(props: {
period?: string;
from?: string | number;
to?: string | number;
defaultPeriod: string;
}): { from: Date; to: Date } {
const time = timeFilters(props);
const periodMs = time.period ? parse(time.period) : undefined;
if (periodMs) {
return {
from: new Date(Date.now() - periodMs),
to: new Date(),
};
}
if (time.from && time.to) {
return {
from: time.from,
to: time.to,
};
}
if (time.from) {
return {
from: time.from,
to: new Date(),
};
}
const defaultPeriodMs = parse(props.defaultPeriod) ?? 24 * 60 * 60 * 1_000;
return {
from: new Date(Date.now() - defaultPeriodMs),
to: time.to ?? new Date(),
};
}
export function timeFilterRenderValues({
from,
to,
@@ -257,7 +295,12 @@ export function timeFilterRenderValues({
case "range":
{
//If the day is the same, only show the time for the `to` date
const isSameDay = from && to && from.getDate() === to.getDate() && from.getMonth() === to.getMonth() && from.getFullYear() === to.getFullYear();
const isSameDay =
from &&
to &&
from.getDate() === to.getDate() &&
from.getMonth() === to.getMonth() &&
from.getFullYear() === to.getFullYear();
valueLabel = (
<span>
@@ -279,8 +322,8 @@ export function timeFilterRenderValues({
rangeType === "range" || rangeType === "period"
? labelName
: rangeType === "from"
? `${labelName} after`
: `${labelName} before`;
? `${labelName} after`
: `${labelName} before`;
return { label, valueLabel, rangeType };
}
@@ -305,6 +348,8 @@ export interface TimeFilterProps {
onValueChange?: (values: TimeFilterApplyValues) => void;
/** When set an upgrade message will be shown if you select a period further back than this number of days */
maxPeriodDays?: number;
/** Optional className override for the value text in the filter pill */
valueClassName?: string;
}
export function TimeFilter({
@@ -317,6 +362,7 @@ export function TimeFilter({
applyShortcut,
onValueChange,
maxPeriodDays,
valueClassName,
}: TimeFilterProps = {}) {
const { value } = useSearchParams();
const periodValue = period ?? value("period");
@@ -343,6 +389,7 @@ export function TimeFilter({
value={constrained.valueLabel}
removable={false}
variant="secondary/small"
valueClassName={valueClassName}
/>
</Ariakit.Select>
}
@@ -443,7 +490,8 @@ export function TimeDropdown({
if (!maxPeriodDays) return false;
if (activeSection === "duration") {
const periodToCheck = selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
const periodToCheck =
selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
if (!periodToCheck) return false;
return periodToDays(periodToCheck) > maxPeriodDays;
} else {
@@ -456,7 +504,9 @@ export function TimeDropdown({
setValidationError(null);
if (exceedsMaxPeriod) {
setValidationError(`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`);
setValidationError(
`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`
);
return;
}
@@ -472,7 +522,11 @@ export function TimeDropdown({
periodToApply = `${customValue}${customUnit}`;
}
const values: TimeFilterApplyValues = { period: periodToApply, from: undefined, to: undefined };
const values: TimeFilterApplyValues = {
period: periodToApply,
from: undefined,
to: undefined,
};
if (onValueChange) {
// Controlled mode - just call the handler
@@ -538,7 +592,7 @@ export function TimeDropdown({
onApply,
onValueChange,
exceedsMaxPeriod,
maxPeriodDays
maxPeriodDays,
]);
return (
@@ -579,9 +633,9 @@ export function TimeDropdown({
? "border-indigo-500 "
: "border-charcoal-650 hover:border-charcoal-600",
validationError &&
activeSection === "duration" &&
selectedPeriod === "custom" &&
"border-error"
activeSection === "duration" &&
selectedPeriod === "custom" &&
"border-error"
)}
onClick={(e) => e.stopPropagation()}
>
@@ -800,7 +854,14 @@ export function TimeDropdown({
{exceedsMaxPeriod && organization && (
<Callout
variant="pricing"
cta={<LinkButton variant="primary/small" to={organizationBillingPath({ slug: organization.slug })}>Upgrade</LinkButton>}
cta={
<LinkButton
variant="primary/small"
to={organizationBillingPath({ slug: organization.slug })}
>
Upgrade
</LinkButton>
}
className="items-center"
>
{simplur`Your plan allows a maximum of ${maxPeriodDays} day[|s].`}
@@ -810,7 +871,7 @@ export function TimeDropdown({
{/* Action buttons */}
<div className="flex justify-between gap-1 border-t border-grid-bright px-0 pt-3">
<Button
variant="tertiary/small"
variant="secondary/small"
onClick={(e) => {
e.preventDefault();
setFromValue(from);
@@ -824,11 +885,15 @@ export function TimeDropdown({
</Button>
<Button
variant="primary/small"
shortcut={applyShortcut ? applyShortcut : {
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
shortcut={
applyShortcut
? applyShortcut
: {
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}
}
onClick={(e) => {
e.preventDefault();
applySelection();
@@ -304,3 +304,42 @@ export const runStatusTitleFromStatus: Record<TaskRunStatus, RunFriendlyStatus>
};
const titlesStatusesArray = Object.entries(runStatusTitleFromStatus);
/**
* Hex color for each TaskRunStatus, mirroring `runStatusClassNameColor` but as
* concrete hex values for non-CSS contexts (e.g. chart series colors).
*/
const RUN_STATUS_HEX_COLORS: Record<TaskRunStatus, string> = {
PENDING: "#5F6570", // charcoal-500
DELAYED: "#6B7580", // charcoal ~450
PENDING_VERSION: "#f59e0b", // amber-500
WAITING_FOR_DEPLOY: "#d97706", // amber-600
EXECUTING: "#3b82f6", // blue-500
RETRYING_AFTER_FAILURE: "#2f6fec", // blue ~550
DEQUEUED: "#4D8EF5", // blue ~475
WAITING_TO_RESUME: "#555D67", // charcoal ~550
PAUSED: "#fbbf24", // amber-400
CANCELED: "#78828C", // charcoal ~400
EXPIRED: "#848D96", // charcoal ~350
INTERRUPTED: "#D52C4D", // rose — evenly spaced (error)
COMPLETED_SUCCESSFULLY: "#28BF5C", // mint-500 (success)
COMPLETED_WITH_ERRORS: "#DE405C", // rose — evenly spaced (error)
SYSTEM_FAILURE: "#E7536C", // rose — evenly spaced (error)
CRASHED: "#cc193d", // rose — darkest (error)
TIMED_OUT: "#F0667B", // rose — lightest (error)
};
/**
* Get the hex color for a run status value. Accepts either a raw TaskRunStatus
* (e.g. "COMPLETED_SUCCESSFULLY") or a friendly name (e.g. "Completed").
* Returns `undefined` when the value is not a recognised status.
*/
export function getRunStatusHexColor(value: string): string | undefined {
if (isTaskRunStatus(value)) {
return RUN_STATUS_HEX_COLORS[value];
}
if (isRunFriendlyStatus(value)) {
return RUN_STATUS_HEX_COLORS[runStatusFromFriendlyTitle(value)];
}
return undefined;
}
+4
View File
@@ -1199,6 +1199,10 @@ const EnvironmentSchema = z
QUERY_FEATURE_ENABLED: z.string().default("1"),
// Query page ClickHouse limits (for TSQL queries)
QUERY_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
+515
View File
@@ -0,0 +1,515 @@
import { useReducer, useCallback, useRef, useEffect } from "react";
import { nanoid } from "nanoid";
import type {
DashboardLayout,
LayoutItem,
Widget,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import type { WidgetData, QueryWidgetConfig } from "~/components/metrics/QueryWidget";
// ============================================================================
// Types
// ============================================================================
type EditorMode =
| null
| { type: "add" }
| { type: "edit"; widgetId: string; widget: WidgetData };
type DashboardState = {
/** The layout items (positions/sizes) */
layout: LayoutItem[];
/** The widget configurations keyed by widget ID */
widgets: Record<string, Widget>;
/** Current editor mode (add/edit/closed) */
editorMode: EditorMode;
};
// ============================================================================
// Actions
// ============================================================================
type DashboardAction =
| { type: "ADD_WIDGET"; payload: { id: string; widget: Widget; layoutItem: LayoutItem } }
| { type: "UPDATE_WIDGET"; payload: { id: string; widget: Widget } }
| { type: "RENAME_WIDGET"; payload: { id: string; title: string } }
| { type: "DELETE_WIDGET"; payload: { id: string } }
| { type: "DUPLICATE_WIDGET"; payload: { id: string; newId: string } }
| { type: "UPDATE_LAYOUT"; payload: { layout: LayoutItem[] } }
| { type: "RESET_STATE"; payload: { layout: LayoutItem[]; widgets: Record<string, Widget> } }
| { type: "OPEN_ADD_EDITOR" }
| { type: "OPEN_EDIT_EDITOR"; payload: { widgetId: string; widget: WidgetData } }
| { type: "CLOSE_EDITOR" };
// ============================================================================
// Reducer
// ============================================================================
function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
switch (action.type) {
case "ADD_WIDGET":
return {
...state,
layout: [...state.layout, action.payload.layoutItem],
widgets: {
...state.widgets,
[action.payload.id]: action.payload.widget,
},
editorMode: null,
};
case "UPDATE_WIDGET":
return {
...state,
widgets: {
...state.widgets,
[action.payload.id]: action.payload.widget,
},
editorMode: null,
};
case "RENAME_WIDGET": {
const existingWidget = state.widgets[action.payload.id];
if (!existingWidget) return state;
return {
...state,
widgets: {
...state.widgets,
[action.payload.id]: {
...existingWidget,
title: action.payload.title,
},
},
};
}
case "DELETE_WIDGET": {
const { [action.payload.id]: _, ...remainingWidgets } = state.widgets;
return {
...state,
layout: state.layout.filter((item) => item.i !== action.payload.id),
widgets: remainingWidgets,
};
}
case "DUPLICATE_WIDGET": {
const original = state.widgets[action.payload.id];
const originalLayout = state.layout.find((l) => l.i === action.payload.id);
if (!original || !originalLayout) return state;
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
// Deep copy the widget to ensure no shared references
// This prevents edits to one widget from affecting the duplicate
const duplicatedWidget: Widget = {
title: `${original.title} (Copy)`,
query: original.query,
display: JSON.parse(JSON.stringify(original.display)) as QueryWidgetConfig,
};
return {
...state,
layout: [
...state.layout,
{ ...originalLayout, i: action.payload.newId, y: maxBottom, x: 0 },
],
widgets: {
...state.widgets,
[action.payload.newId]: duplicatedWidget,
},
};
}
case "UPDATE_LAYOUT":
return { ...state, layout: action.payload.layout };
case "RESET_STATE":
return {
...state,
layout: action.payload.layout,
widgets: action.payload.widgets,
};
case "OPEN_ADD_EDITOR":
return { ...state, editorMode: { type: "add" } };
case "OPEN_EDIT_EDITOR":
return {
...state,
editorMode: { type: "edit", widgetId: action.payload.widgetId, widget: action.payload.widget },
};
case "CLOSE_EDITOR":
return { ...state, editorMode: null };
default:
return state;
}
}
// ============================================================================
// Hook Options
// ============================================================================
export type UseDashboardEditorOptions = {
/** Initial dashboard layout data from the server */
initialData: DashboardLayout;
/** URL for widget actions (add, update, delete, duplicate) */
widgetActionUrl: string;
/** URL for layout updates. If empty or not provided, uses current page URL. */
layoutActionUrl?: string;
/** Maximum number of widgets allowed per dashboard. If not provided, no limit is enforced. */
widgetLimit?: number;
/** Callback when a sync error occurs */
onSyncError?: (error: Error, action: string) => void;
/** Callback when a widget action is blocked by the limit */
onWidgetLimitReached?: () => void;
};
// ============================================================================
// Sync Queue Types
// ============================================================================
type WidgetSyncTask = {
type: "widget";
action: string;
data: Record<string, string>;
};
type LayoutSyncTask = {
type: "layout";
layout: LayoutItem[];
};
type SyncTask = WidgetSyncTask | LayoutSyncTask;
// ============================================================================
// Hook
// ============================================================================
export function useDashboardEditor({
initialData,
widgetActionUrl,
layoutActionUrl,
widgetLimit,
onSyncError,
onWidgetLimitReached,
}: UseDashboardEditorOptions) {
const [state, dispatch] = useReducer(dashboardReducer, {
layout: initialData.layout,
widgets: initialData.widgets,
editorMode: null,
});
// Refs for debouncing and tracking initialization
const layoutDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isInitializedRef = useRef(false);
const currentLayoutJsonRef = useRef<string>(JSON.stringify(initialData.layout));
// Sync queue to prevent race conditions
const syncQueueRef = useRef<SyncTask[]>([]);
const isSyncingRef = useRef(false);
// Reset state when initialData changes (e.g., navigating to different dashboard)
const initialDataJson = JSON.stringify({ layout: initialData.layout, widgets: initialData.widgets });
useEffect(() => {
// Cancel any pending layout save
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
layoutDebounceRef.current = null;
}
// Clear the sync queue when switching dashboards
syncQueueRef.current = [];
// Reset state to new initial data
dispatch({
type: "RESET_STATE",
payload: { layout: initialData.layout, widgets: initialData.widgets },
});
// Update refs
currentLayoutJsonRef.current = JSON.stringify(initialData.layout);
isInitializedRef.current = false;
// Allow saves after a short delay to skip initial mount callbacks
const initTimeout = setTimeout(() => {
isInitializedRef.current = true;
}, 100);
return () => {
clearTimeout(initTimeout);
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
}
};
}, [initialDataJson]);
// -------------------------------------------------------------------------
// Sync queue processor - ensures only one sync runs at a time
// -------------------------------------------------------------------------
const processNextSync = useCallback(async () => {
// If already syncing or queue is empty, do nothing
if (isSyncingRef.current || syncQueueRef.current.length === 0) {
return;
}
isSyncingRef.current = true;
const task = syncQueueRef.current.shift()!;
try {
if (task.type === "widget") {
const formData = new FormData();
formData.set("action", task.action);
Object.entries(task.data).forEach(([k, v]) => formData.set(k, v));
const response = await fetch(widgetActionUrl, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to ${task.action} widget: ${errorText}`);
}
} else if (task.type === "layout") {
const formData = new FormData();
formData.set("action", "layout");
formData.set("layout", JSON.stringify(task.layout));
// Use current page URL if layoutActionUrl is not provided
const url = layoutActionUrl || window.location.pathname;
const response = await fetch(url, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error("Failed to update layout: " + errorText);
}
}
} catch (error) {
console.error(`Dashboard sync error:`, error);
const actionName = task.type === "widget" ? task.action : "layout";
onSyncError?.(error instanceof Error ? error : new Error(String(error)), actionName);
} finally {
isSyncingRef.current = false;
// Process next item in queue
processNextSync();
}
}, [widgetActionUrl, layoutActionUrl, onSyncError]);
// -------------------------------------------------------------------------
// Queue helpers
// -------------------------------------------------------------------------
const queueWidgetSync = useCallback(
(action: string, data: Record<string, string>) => {
syncQueueRef.current.push({ type: "widget", action, data });
processNextSync();
},
[processNextSync]
);
const queueLayoutSync = useCallback(
(layout: LayoutItem[]) => {
// For layout syncs, we only care about the latest state
// Remove any pending layout syncs and add the new one
syncQueueRef.current = syncQueueRef.current.filter((task) => task.type !== "layout");
syncQueueRef.current.push({ type: "layout", layout });
processNextSync();
},
[processNextSync]
);
// -------------------------------------------------------------------------
// Action handlers
// -------------------------------------------------------------------------
// Count only non-title widgets for limit checks (title widgets are free)
const countedWidgets = Object.values(state.widgets).filter(
(w) => w.display.type !== "title"
).length;
const addWidget = useCallback(
(title: string, query: string, config: QueryWidgetConfig) => {
// Guard: check widget limit (title widgets don't count)
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
onWidgetLimitReached?.();
return;
}
const id = nanoid(8);
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 15 };
const widget: Widget = { title, query, display: config };
// Update local state immediately
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
// Queue sync to server (processed sequentially)
// Send the client-generated ID so the server uses the same ID
queueWidgetSync("add", {
widgetId: id,
title,
query,
config: JSON.stringify(config),
});
},
[state.layout, countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
);
const addTitleWidget = useCallback(
(title: string) => {
const id = nanoid(8);
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
// Title widgets are fixed at h=2 and full width
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 2 };
const config: QueryWidgetConfig = { type: "title" };
const widget: Widget = { title, query: "", display: config };
// Update local state immediately
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
// Queue sync to server (processed sequentially)
// Send the client-generated ID so the server uses the same ID
queueWidgetSync("add", {
widgetId: id,
title,
query: "",
config: JSON.stringify(config),
});
},
[state.layout, queueWidgetSync]
);
const updateWidget = useCallback(
(widgetId: string, title: string, query: string, config: QueryWidgetConfig) => {
const widget: Widget = { title, query, display: config };
// Update local state immediately
dispatch({ type: "UPDATE_WIDGET", payload: { id: widgetId, widget } });
// Queue sync to server (processed sequentially)
queueWidgetSync("update", {
widgetId,
title,
query,
config: JSON.stringify(config),
});
},
[queueWidgetSync]
);
const deleteWidget = useCallback(
(widgetId: string) => {
// Update local state immediately
dispatch({ type: "DELETE_WIDGET", payload: { id: widgetId } });
// Queue sync to server (processed sequentially)
queueWidgetSync("delete", { widgetId });
},
[queueWidgetSync]
);
const duplicateWidget = useCallback(
(widgetId: string) => {
// Guard: check widget limit (title widgets don't count)
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
onWidgetLimitReached?.();
return;
}
const newId = nanoid(8);
// Update local state immediately
dispatch({ type: "DUPLICATE_WIDGET", payload: { id: widgetId, newId } });
// Queue sync to server (processed sequentially)
// Send the client-generated newId so the server uses the same ID for the duplicate
queueWidgetSync("duplicate", { widgetId, newId });
},
[countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
);
const renameWidget = useCallback(
(widgetId: string, title: string) => {
// Update local state immediately
dispatch({ type: "RENAME_WIDGET", payload: { id: widgetId, title } });
// Queue sync to server (processed sequentially)
queueWidgetSync("rename", { widgetId, title });
},
[queueWidgetSync]
);
const updateLayout = useCallback(
(newLayout: LayoutItem[]) => {
// Skip if not yet initialized (prevents saving during mount/navigation)
if (!isInitializedRef.current) {
return;
}
const newLayoutJson = JSON.stringify(newLayout);
// Skip if layout hasn't actually changed
if (newLayoutJson === currentLayoutJsonRef.current) {
return;
}
// Update local state immediately
dispatch({ type: "UPDATE_LAYOUT", payload: { layout: newLayout } });
// Clear existing debounce timeout
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
}
// Debounce before queueing - this ensures rapid layout changes
// (like dragging) don't queue up many requests
layoutDebounceRef.current = setTimeout(() => {
currentLayoutJsonRef.current = newLayoutJson;
// Queue layout sync (replaces any pending layout sync in queue)
queueLayoutSync(newLayout);
}, 500);
},
[queueLayoutSync]
);
const openAddEditor = useCallback(() => {
dispatch({ type: "OPEN_ADD_EDITOR" });
}, []);
const openEditEditor = useCallback((widgetId: string, widget: WidgetData) => {
dispatch({ type: "OPEN_EDIT_EDITOR", payload: { widgetId, widget } });
}, []);
const closeEditor = useCallback(() => {
dispatch({ type: "CLOSE_EDITOR" });
}, []);
// -------------------------------------------------------------------------
// Return value
// -------------------------------------------------------------------------
return {
/** Current dashboard state */
state,
/** Action dispatchers */
actions: {
addWidget,
addTitleWidget,
updateWidget,
renameWidget,
deleteWidget,
duplicateWidget,
updateLayout,
openAddEditor,
openEditEditor,
closeEditor,
},
};
}
@@ -0,0 +1,35 @@
import { useEffect, useRef } from "react";
type UseElementVisibilityOptions = {
onVisibilityChange?: (isVisible: boolean) => void;
};
export function useElementVisibility({
onVisibilityChange,
}: UseElementVisibilityOptions = {}) {
const ref = useRef<HTMLDivElement>(null);
const isVisibleRef = useRef(false);
const callbackRef = useRef(onVisibilityChange);
callbackRef.current = onVisibilityChange;
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
const nowVisible = entry.isIntersecting;
if (isVisibleRef.current !== nowVisible) {
isVisibleRef.current = nowVisible;
callbackRef.current?.(nowVisible);
}
},
{ threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, isVisibleRef };
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
type UseIntervalOptions = {
/** If passed, will refresh every interval MS */
interval?: number;
onLoad?: boolean;
onFocus?: boolean;
disabled?: boolean;
callback: () => void;
};
export function useInterval({
interval,
onLoad = true,
onFocus = true,
disabled = false,
callback,
}: UseIntervalOptions) {
// Always keep the latest callback in a ref so the effects below
// never close over a stale version.
const latestCallback = useRef(callback);
useEffect(() => {
latestCallback.current = callback;
}, [callback]);
// On interval
useEffect(() => {
if (!interval || interval <= 0 || disabled) return;
const intervalId = setInterval(() => {
latestCallback.current();
}, interval);
return () => clearInterval(intervalId);
}, [interval, disabled]);
// On focus
useEffect(() => {
if (!onFocus || disabled) return;
const handleFocus = () => {
if (document.visibilityState === "visible") {
latestCallback.current();
}
};
// Revalidate when the page becomes visible
document.addEventListener("visibilitychange", handleFocus);
// Revalidate when the window gains focus
window.addEventListener("focus", handleFocus);
return () => {
document.removeEventListener("visibilitychange", handleFocus);
window.removeEventListener("focus", handleFocus);
};
}, [onFocus, disabled]);
// On load
useEffect(() => {
if (disabled || !onLoad) return;
latestCallback.current();
}, [disabled, onLoad]);
}
+26
View File
@@ -61,3 +61,29 @@ export function useIsImpersonating(matches?: UIMatch[]) {
});
return data?.isImpersonating === true;
}
export type CustomDashboard = UseDataFunctionReturn<typeof orgLoader>["customDashboards"][number];
export function useCustomDashboards(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.customDashboards ?? [];
}
export function useDashboardLimits(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.dashboardLimits ?? { used: 0, limit: 3 };
}
export function useWidgetLimitPerDashboard(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.widgetLimitPerDashboard ?? 16;
}
@@ -0,0 +1,57 @@
import { useEffect } from "react";
import { useRevalidator, useSearchParams } from "@remix-run/react";
type UseRevalidateOnParamOptions = {
/** The query param(s) that trigger revalidation */
param: string | string[];
/** Callback fired when revalidation is triggered */
onRevalidate?: () => void;
};
/**
* Hook that triggers revalidation when specific query params are present,
* then removes those params from the URL.
*
* Usage:
* ```ts
* // Revalidate when ?_revalidate is present
* useRevalidateOnParam({ param: "_revalidate" });
*
* // With callback to close a modal
* useRevalidateOnParam({
* param: "_revalidate",
* onRevalidate: () => setEditorMode(null),
* });
* ```
*
* The redirect should include the param:
* ```ts
* return redirect(`${dashboardPath}?_revalidate=${Date.now()}`);
* ```
*/
export function useRevalidateOnParam({ param, onRevalidate }: UseRevalidateOnParamOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const revalidator = useRevalidator();
const paramArray = Array.isArray(param) ? param : [param];
useEffect(() => {
// Check if any of the trigger params are present
const hasParam = paramArray.some((p) => searchParams.has(p));
if (hasParam) {
// Trigger revalidation
revalidator.revalidate();
// Call the callback if provided
onRevalidate?.();
// Remove the trigger params from the URL
const newParams = new URLSearchParams(searchParams);
paramArray.forEach((p) => newParams.delete(p));
// Update URL without the params (replace to avoid adding to history)
setSearchParams(newParams, { replace: true });
}
}, [searchParams, setSearchParams, revalidator, paramArray, onRevalidate]);
}
@@ -308,3 +308,26 @@ export async function findDisplayableEnvironment(
return displayableEnvironment(environment, userId);
}
export async function hasAccessToEnvironment({
environmentId,
projectId,
organizationId,
userId,
}: {
environmentId: string;
projectId: string;
organizationId: string;
userId: string;
}): Promise<boolean> {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id: environmentId,
projectId: projectId,
organizationId: organizationId,
organization: { members: { some: { userId } } },
},
});
return environment !== null;
}
@@ -0,0 +1,225 @@
import { type BuiltInDashboard } from "./MetricDashboardPresenter.server";
import { z } from "zod";
const overviewDashboard: BuiltInDashboard = {
key: "overview",
title: "Metrics",
layout: {
version: "1",
layout: [
{ i: "9lDDdebQ", x: 3, y: 0, w: 3, h: 4 },
{ i: "VhAgNlB0", x: 0, y: 0, w: 3, h: 4 },
{ i: "iI5EnhJW", x: 6, y: 0, w: 3, h: 4 },
{ i: "HtSgJEmp", x: 0, y: 17, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "rRbzv-Aq", x: 6, y: 4, w: 6, h: 13 },
{ i: "j3yFSxLM", x: 0, y: 33, w: 6, h: 11 },
{ i: "IKB8cENo", x: 6, y: 33, w: 6, h: 11 },
{ i: "-fHz3CyQ", x: 0, y: 56, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "hnKsN482", x: 0, y: 58, w: 12, h: 15 },
{ i: "if6dds8T", x: 0, y: 19, w: 12, h: 14 },
{ i: "i3q1Awfz", x: 0, y: 4, w: 6, h: 13 },
{ i: "Kh0w0fjy", x: 6, y: 44, w: 6, h: 12 },
{ i: "zybRTAdz", x: 0, y: 44, w: 6, h: 12 },
{ i: "ff2nVxxt", x: 0, y: 73, w: 12, h: 15 },
{ i: "Dib0ywb4", x: 0, y: 88, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "YsWiQENd", x: 0, y: 90, w: 12, h: 15 },
{ i: "lc-guCvo", x: 0, y: 105, w: 12, h: 15 },
{ i: "xyQl3FAd", x: 9, y: 0, w: 3, h: 4 },
],
widgets: {
"9lDDdebQ": {
title: "Total runs",
query: "SELECT\r\n count() AS total_runs\r\nFROM\r\n runs\r\nLIMIT\r\n 100",
display: { type: "bignumber", column: "total_runs", aggregation: "sum", abbreviate: false },
},
VhAgNlB0: {
title: "Success %",
query:
"SELECT\r\n round(countIf (status = 'Completed') * 100.0 / countIf (is_finished = 1), 2) AS success_percentage\r\nFROM\r\n runs\r\nLIMIT\r\n 100",
display: {
type: "bignumber",
column: "success_percentage",
aggregation: "sum",
abbreviate: true,
suffix: "%",
},
},
iI5EnhJW: {
title: "Failed runs",
query:
"SELECT\r\n count() AS total_runs\r\nFROM\r\n runs\r\nWHERE status IN ('Failed', 'System failure', 'Crashed')\r\nLIMIT\r\n 100",
display: { type: "bignumber", column: "total_runs", aggregation: "sum", abbreviate: false },
},
HtSgJEmp: { title: "Failed runs", query: "", display: { type: "title" } },
"rRbzv-Aq": {
title: "Runs by status",
query:
"SELECT\r\n timeBucket (),\r\n status,\r\n count() AS run_count\r\nFROM\r\n runs\r\nGROUP BY\r\n timeBucket,\r\n status\r\nORDER BY\r\n timeBucket\r\nLIMIT\r\n 100",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["run_count"],
groupByColumn: "status",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
j3yFSxLM: {
title: "Top failing tasks",
query:
"SELECT\r\n task_identifier AS task,\r\n count() AS runs,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) AS failures,\r\n concat(round((countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) * 100, 2), '%') AS failure_rate,\r\n avg(attempt_count - 1) AS avg_retries\r\nFROM\r\n runs\r\nGROUP BY\r\n task_identifier\r\nORDER BY\r\n (countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) DESC\r\nLIMIT\r\n 100;",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
IKB8cENo: {
title: "Top failing tags",
query:
"SELECT\r\n arrayJoin(tags) AS tag,\r\n count() AS runs,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) AS failures,\r\n concat(round((countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) * 100, 2), '%') AS failure_rate,\r\n avg(attempt_count - 1) AS avg_retries\r\nFROM\r\n runs\r\nGROUP BY\r\n tag\r\nORDER BY\r\n (countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) DESC\r\nLIMIT\r\n 100;",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"-fHz3CyQ": { title: "Usage and cost", query: "", display: { type: "title" } },
hnKsN482: {
title: "Cost by task",
query:
"SELECT\r\n timeBucket() as time_period,\r\n task_identifier,\r\n sum(total_cost) AS total_cost\r\nFROM\r\n runs\r\nGROUP BY\r\n time_period,\r\n task_identifier\r\nORDER BY\r\n time_period\r\nLIMIT\r\n 100",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "time_period",
yAxisColumns: ["total_cost"],
groupByColumn: "task_identifier",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
if6dds8T: {
title: "Failed runs by task",
query:
"SELECT\r\n timeBucket () as time_period,\r\n task_identifier,\r\n count() AS run_count\r\nFROM\r\n runs\r\nWHERE status IN ('Failed', 'Crashed', 'System failure')\r\nGROUP BY\r\n time_period,\r\n task_identifier\r\nORDER BY\r\n time_period\r\nLIMIT\r\n 100",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "time_period",
yAxisColumns: ["run_count"],
groupByColumn: "task_identifier",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
i3q1Awfz: {
title: "Run success",
query:
"SELECT\r\n timeBucket (),\r\n count() as total,\r\n countIf (status = 'Completed') / total * 100 AS completed,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) / total * 100 AS failed,\r\nFROM\r\n runs\r\nGROUP BY\r\n timeBucket\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["failed", "completed"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: { failed: "#f43f5e" },
},
},
Kh0w0fjy: {
title: "Top errors",
query:
"SELECT\r\n concat(error.name, '(\"', error.message, '\")') AS error,\r\n count() AS count\r\nFROM\r\n runs\r\nWHERE\r\n runs.error != NULL\r\n AND runs.error.name != NULL\r\nGROUP BY\r\n error\r\nORDER BY\r\n count DESC\r\nLIMIT\r\n 100",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
zybRTAdz: {
title: "Top errors over time",
query:
"SELECT\r\n timeBucket(),\r\n concat(error.name, '(\"', error.message, '\")') AS error,\r\n count() AS count\r\nFROM\r\n runs\r\nWHERE\r\n runs.error != NULL\r\n AND runs.error.name != NULL\r\nGROUP BY\r\n timeBucket,\r\n error\r\nORDER BY\r\n count DESC\r\nLIMIT\r\n 100",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["count"],
groupByColumn: "error",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: { count: "#ef4343" },
},
},
ff2nVxxt: {
title: "Cost by machine",
query:
"SELECT\r\n timeBucket() as time_period,\r\n machine,\r\n sum(total_cost) AS total_cost\r\nFROM\r\n runs\r\nWHERE machine != ''\r\nGROUP BY\r\n time_period,\r\n machine\r\nORDER BY\r\n time_period\r\nLIMIT\r\n 100",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "time_period",
yAxisColumns: ["total_cost"],
groupByColumn: "machine",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
Dib0ywb4: { title: "Versions", query: "", display: { type: "title" } },
YsWiQENd: {
title: "Runs by version",
query:
"SELECT\r\n timeBucket (),\r\n task_version,\r\n count() as runs\r\nFROM\r\n runs\r\nWHERE task_version != ''\r\nGROUP BY\r\n timeBucket,\r\n task_version\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["runs"],
groupByColumn: "task_version",
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
},
},
"lc-guCvo": {
title: "Version success %",
query:
"SELECT\r\n timeBucket (),\r\n task_version,\r\n count() as total,\r\n countIf (status = 'Completed') / total * 100 AS success\r\nFROM\r\n runs\r\nWHERE task_version != ''\r\nGROUP BY\r\n timeBucket,\r\n task_version\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["success"],
groupByColumn: "task_version",
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
},
},
xyQl3FAd: {
title: "Queued",
query:
"SELECT\r\n count() AS queued\r\nFROM\r\n runs\r\nWHERE status IN ('Dequeued', 'Queued')\r\nLIMIT\r\n 100",
display: { type: "bignumber", column: "queued", aggregation: "sum", abbreviate: false },
},
},
},
};
const builtInDashboards: BuiltInDashboard[] = [overviewDashboard];
export function builtInDashboard(key: string): BuiltInDashboard {
const dashboard = builtInDashboards.find((d) => d.key === key);
if (!dashboard) {
throw new Error(`No built-in dashboard "${key}"`);
}
return dashboard;
}
@@ -68,6 +68,9 @@ export type LimitsResult = {
batchProcessingConcurrency: QuotaInfo;
devQueueSize: QuotaInfo;
deployedQueueSize: QuotaInfo;
metricDashboards: QuotaInfo | null;
metricWidgetsPerDashboard: QuotaInfo | null;
queryPeriodDays: QuotaInfo | null;
};
features: {
hasStagingEnvironment: FeatureInfo;
@@ -155,6 +158,11 @@ export class LimitsPresenter extends BasePresenter {
},
});
// Get metric dashboard count for this org
const metricDashboardCount = await this._replica.metricsDashboard.count({
where: { organizationId },
});
// Get current rate limit tokens for this environment's API key
const apiRateLimitTokens = await getRateLimitRemainingTokens(
"api",
@@ -174,6 +182,9 @@ export class LimitsPresenter extends BasePresenter {
const branchesLimit = limits?.branches?.number ?? null;
const logRetentionDaysLimit = limits?.logRetentionDays?.number ?? null;
const realtimeConnectionsLimit = limits?.realtimeConcurrentConnections?.number ?? null;
const metricDashboardsLimit = limits?.metricDashboards?.number ?? null;
const metricWidgetsPerDashboardLimit = limits?.metricWidgetsPerDashboard?.number ?? null;
const queryPeriodDaysLimit = limits?.queryPeriodDays?.number ?? null;
const includedUsage = limits?.includedUsage ?? null;
const hasStagingEnvironment = limits?.hasStagingEnvironment ?? false;
const supportLevel = limits?.support ?? "community";
@@ -296,6 +307,40 @@ export class LimitsPresenter extends BasePresenter {
currentUsage: 0, // Would need to query Redis for this
source: organization.maximumDeployedQueueSize ? "override" : "default",
},
metricDashboards:
metricDashboardsLimit !== null
? {
name: "Metric dashboards",
description: "Maximum number of custom metric dashboards per organization",
limit: metricDashboardsLimit,
currentUsage: metricDashboardCount,
source: "plan",
canExceed: limits?.metricDashboards?.canExceed,
isUpgradable: true,
}
: null,
metricWidgetsPerDashboard:
metricWidgetsPerDashboardLimit !== null
? {
name: "Charts per dashboard",
description: "Maximum number of charts per metrics dashboard",
limit: metricWidgetsPerDashboardLimit,
currentUsage: 0, // Varies per dashboard
source: "plan",
canExceed: limits?.metricWidgetsPerDashboard?.canExceed,
isUpgradable: true,
}
: null,
queryPeriodDays:
queryPeriodDaysLimit !== null
? {
name: "Query period",
description: "Maximum number of days a query can look back",
limit: queryPeriodDaysLimit,
currentUsage: 0, // Not applicable - this is a duration, not a count
source: "plan",
}
: null,
},
features: {
hasStagingEnvironment: {
@@ -0,0 +1,123 @@
import { BasePresenter } from "./basePresenter.server";
import { type QueryScope } from "~/services/queryService.server";
import { getLimit } from "~/services/platform.v3.server";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import { builtInDashboard } from "./BuiltInDashboards.server";
import { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
export type MetricFilters = {
/** Org, project, environment */
scope: QueryScope;
/** Time filter settings */
filterPeriod: string | null;
filterFrom: Date | null;
filterTo: Date | null;
/** Tasks */
taskIdentifiers?: string[];
/** Queues */
queues?: string[];
/** Tags */
tags?: string[];
};
export const LayoutItem = z.object({
i: z.string(),
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
minH: z.number().optional(),
maxH: z.number().optional(),
});
export type LayoutItem = z.infer<typeof LayoutItem>;
export const Widget = z.object({
title: z.string(),
query: z.string().default(""),
display: QueryWidgetConfig,
});
export type Widget = z.infer<typeof Widget>;
export const DashboardLayout = z.discriminatedUnion("version", [
z.object({
version: z.literal("1"),
layout: z.array(LayoutItem),
widgets: z.record(Widget),
}),
]);
export type DashboardLayout = z.infer<typeof DashboardLayout>;
export type CustomDashboard = {
friendlyId: string;
title: string;
layout: DashboardLayout;
defaultPeriod: string;
};
export type BuiltInDashboard = {
key: string;
title: string;
layout: DashboardLayout;
};
/** Returns the dashboard layout */
export class MetricDashboardPresenter extends BasePresenter {
public async customDashboard({
friendlyId,
organizationId,
}: {
friendlyId: string;
organizationId: string;
}): Promise<CustomDashboard> {
const dashboard = await this._replica.metricsDashboard.findFirst({
where: { friendlyId, organizationId },
});
if (!dashboard) {
throw new Error("No dashboard found");
}
const layout = this.#getLayout(dashboard.layout);
const defaultPeriod = await getDashboardDefaultPeriod(organizationId);
return {
friendlyId: dashboard.friendlyId,
title: dashboard.title,
layout,
defaultPeriod,
};
}
public async builtInDashboard({ organizationId, key }: { organizationId: string; key: string }) {
const defaultPeriod = await getDashboardDefaultPeriod(organizationId);
const dashboard = builtInDashboard(key);
return {
...dashboard,
defaultPeriod,
};
}
#getLayout(layoutData: string): DashboardLayout {
const json = JSON.parse(layoutData);
const parsedLayout = DashboardLayout.safeParse(json);
if (!parsedLayout.success) {
throw fromZodError(parsedLayout.error);
}
return parsedLayout.data;
}
}
/** Dashboard-specific default period (1 day), capped to the org's max query period */
async function getDashboardDefaultPeriod(organizationId: string): Promise<string> {
const idealDefaultPeriodDays = 1;
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
if (maxQueryPeriod < idealDefaultPeriodDays) {
return `${maxQueryPeriod}d`;
}
return `${idealDefaultPeriodDays}d`;
}
@@ -511,6 +511,11 @@ function QuotasSection({
if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize);
if (quotas.deployedQueueSize.limit !== null) quotaRows.push(quotas.deployedQueueSize);
// Metric & query quotas
if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards);
if (quotas.metricWidgetsPerDashboard) quotaRows.push(quotas.metricWidgetsPerDashboard);
if (quotas.queryPeriodDays) quotaRows.push(quotas.queryPeriodDays);
return (
<div className="flex flex-col gap-3">
<Header2 className="flex items-center gap-1">
@@ -555,13 +560,16 @@ function QuotaRow({
isOnTopPlan: boolean;
billingPath: string;
}) {
// For log retention, we don't show current usage as it's a duration, not a count
const isRetentionQuota = quota.name === "Log retention";
// For log retention and query period, we don't show current usage as it's a duration, not a count
// For widgets per dashboard, the usage varies per dashboard so we don't show a single number
const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period";
const isPerItemQuota = quota.name === "Charts per dashboard";
const isRetentionQuota = isDurationQuota || isPerItemQuota;
const percentage =
!isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
// Special handling for Log retention
if (quota.name === "Log retention") {
// Special handling for duration-based quotas (Log retention, Query period)
if (isDurationQuota) {
const canUpgrade = !isOnTopPlan;
return (
<TableRow>
@@ -570,7 +578,9 @@ function QuotaRow({
<InfoIconTooltip content={quota.description} disableHoverableContent />
</TableCell>
<TableCell alignment="right" className="font-medium tabular-nums">
{quota.limit !== null ? `${formatNumber(quota.limit)} days` : "Unlimited"}
{quota.limit !== null
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
: "Unlimited"}
</TableCell>
<TableCell alignment="right" className="tabular-nums text-text-dimmed">
@@ -648,8 +658,8 @@ function QuotaRow({
</TableCell>
<TableCell alignment="right" className="font-medium tabular-nums">
{quota.limit !== null
? isRetentionQuota
? `${formatNumber(quota.limit)} days`
? isDurationQuota
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
: formatNumber(quota.limit)
: "Unlimited"}
</TableCell>
@@ -0,0 +1,295 @@
import type { TaskTriggerSource } from "@trigger.dev/database";
import { $replica } from "~/db.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { requireUser } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import {
type LayoutItem,
type Widget,
MetricDashboardPresenter,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { z } from "zod";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import ReactGridLayout from "react-grid-layout";
import { MetricWidget } from "../resources.metric";
import { TitleWidget } from "~/components/metrics/TitleWidget";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useEnvironment } from "~/hooks/useEnvironment";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type WidgetData } from "~/components/metrics/QueryWidget";
import { QueryScopeSchema } from "~/v3/querySchemas";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardKey: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardKey } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const presenter = new MetricDashboardPresenter();
const [dashboard, possibleTasks] = await Promise.all([
presenter.builtInDashboard({
organizationId: project.organizationId,
key: dashboardKey,
}),
getAllTaskIdentifiers($replica, environment.id),
]);
return typedjson({
...dashboard,
possibleTasks: possibleTasks
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
.sort((a, b) => a.slug.localeCompare(b.slug)),
});
};
export default function Page() {
const {
key,
title,
layout: dashboardLayout,
defaultPeriod,
possibleTasks,
} = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
</NavBar>
<PageBody scrollable={false}>
<div className="h-full">
<MetricDashboard
key={key}
layout={dashboardLayout.layout}
widgets={dashboardLayout.widgets}
defaultPeriod={defaultPeriod}
editable={false}
possibleTasks={possibleTasks}
/>
</div>
</PageBody>
</PageContainer>
);
}
export function MetricDashboard({
layout,
widgets,
defaultPeriod,
editable,
possibleTasks,
onLayoutChange,
onEditWidget,
onRenameWidget,
onDeleteWidget,
onDuplicateWidget,
}: {
/** The layout items (positions/sizes) - fully controlled from parent */
layout: LayoutItem[];
/** The widget configurations keyed by widget ID - fully controlled from parent */
widgets: Record<string, Widget>;
defaultPeriod: string;
editable: boolean;
/** Possible tasks for filtering */
possibleTasks?: { slug: string; triggerSource: TaskTriggerSource }[];
onLayoutChange?: (layout: LayoutItem[]) => void;
onEditWidget?: (widgetId: string, widget: WidgetData) => void;
onRenameWidget?: (widgetId: string, newTitle: string) => void;
onDeleteWidget?: (widgetId: string) => void;
onDuplicateWidget?: (widgetId: string, widget: WidgetData) => void;
}) {
const { value, values } = useSearchParams();
const { width, containerRef, mounted } = useContainerWidth();
const [resizingItemId, setResizingItemId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const isInteracting = resizingItemId !== null || isDragging;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
const period = value("period");
const from = value("from");
const to = value("to");
const parsedScope = QueryScopeSchema.safeParse(value("scope") ?? "environment");
const scope = parsedScope.success ? parsedScope.data : "environment";
const tasks = values("tasks").filter((v) => v !== "");
const queues = values("queues").filter((v) => v !== "");
const handleLayoutChange = useCallback(
(newLayout: readonly LayoutItem[]) => {
const mutableLayout = [...newLayout];
onLayoutChange?.(mutableLayout);
},
[onLayoutChange]
);
// Apply constraints for title widgets: fixed height of 2, allow horizontal resize only
const constrainedLayout = useMemo(
() =>
layout.map((item) => {
const widget = widgets[item.i];
if (widget?.display.type === "title") {
return { ...item, h: 2, minH: 2, maxH: 2 };
}
return item;
}),
[layout, widgets]
);
return (
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-center gap-1 border-b border-b-grid-bright py-2 pl-2 pr-3">
<ScopeFilter />
<LogsTaskFilter possibleTasks={possibleTasks ?? []} />
<QueuesFilter />
<TimeFilter
defaultPeriod={defaultPeriod}
labelName="Period"
hideLabel
maxPeriodDays={maxPeriodDays}
valueClassName="text-text-bright"
/>
</div>
<div
ref={containerRef}
className={cn(
"overflow-y-auto scrollbar-thin scrollbar-track-charcoal-800 scrollbar-thumb-charcoal-700",
isInteracting && "select-none"
)}
>
{mounted && (
<ReactGridLayout
layout={constrainedLayout}
width={width}
gridConfig={{ cols: 12, rowHeight: 30 }}
resizeConfig={{
enabled: editable,
handles: ["se"],
}}
dragConfig={{ enabled: editable, handle: ".drag-handle" }}
onLayoutChange={handleLayoutChange}
onResizeStart={(_layout, oldItem) => setResizingItemId(oldItem?.i ?? null)}
onResizeStop={() => setResizingItemId(null)}
onDragStart={() => setIsDragging(true)}
onDragStop={() => setIsDragging(false)}
>
{Object.entries(widgets).map(([key, widget]) => (
<div key={key}>
{widget.display.type === "title" ? (
<TitleWidget
title={widget.title}
isDraggable={editable}
isResizing={resizingItemId === key}
onRename={
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
}
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
/>
) : (
<MetricWidget
widgetKey={key}
title={widget.title}
query={widget.query}
scope={scope}
period={period ?? null}
from={from ?? null}
to={to ?? null}
taskIdentifiers={tasks.length > 0 ? tasks : undefined}
queues={queues.length > 0 ? queues : undefined}
config={widget.display}
organizationId={organization.id}
projectId={project.id}
environmentId={environment.id}
refreshIntervalMs={60_000}
isResizing={resizingItemId === key}
isDraggable={editable}
onEdit={
onEditWidget
? (resultData) => onEditWidget(key, { ...widget, resultData })
: undefined
}
onRename={
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
}
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
onDuplicate={
onDuplicateWidget
? (resultData) => onDuplicateWidget(key, { ...widget, resultData })
: undefined
}
/>
)}
</div>
))}
</ReactGridLayout>
)}
</div>
</div>
);
}
function useContainerWidth(initialWidth = 1280) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(initialWidth);
const [mounted, setMounted] = useState(false);
const measureWidth = useCallback(() => {
if (containerRef.current) {
setWidth(containerRef.current.offsetWidth);
}
}, []);
useEffect(() => {
measureWidth();
setMounted(true);
const element = containerRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, [measureWidth]);
return { width, containerRef, mounted };
}
@@ -0,0 +1,772 @@
import { ArrowUpCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useNavigation } from "@remix-run/react";
import { IconChartHistogram, IconEdit, IconTypography } from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { toast } from "sonner";
import { z } from "zod";
import { defaultChartConfig } from "~/components/code/ChartConfigPanel";
import { Feedback } from "~/components/Feedback";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverVerticalEllipseTrigger,
} from "~/components/primitives/Popover";
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
import { ToastUI } from "~/components/primitives/Toast";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor";
import { $replica, prisma } from "~/db.server";
import { env } from "~/env.server";
import { useDashboardEditor } from "~/hooks/useDashboardEditor";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization, useWidgetLimitPerDashboard } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
queryPath,
v3BillingPath,
v3BuiltInDashboardPath,
} from "~/utils/pathBuilder";
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { Type } from "lucide-react";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const dashboardPresenter = new MetricDashboardPresenter();
const queryPresenter = new QueryPresenter();
const [dashboard, { defaultQuery, history }, possibleTasks] = await Promise.all([
dashboardPresenter.customDashboard({
friendlyId: dashboardId,
organizationId: project.organizationId,
}),
queryPresenter.call({
organizationId: project.organizationId,
}),
getAllTaskIdentifiers($replica, environment.id),
]);
// Admins and impersonating users can use EXPLAIN
const isAdmin = user.admin || user.isImpersonating;
// Compute widget count from dashboard layout
const widgetCount = Object.keys(dashboard.layout.widgets).length;
return typedjson({
...dashboard,
// Query editor data
queryDefaultQuery: defaultQuery,
queryHistory: history,
isAdmin,
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
possibleTasks: possibleTasks
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
.sort((a, b) => a.slug.localeCompare(b.slug)),
widgetCount,
});
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Load the dashboard
const dashboard = await prisma.metricsDashboard.findFirst({
where: {
friendlyId: dashboardId,
organizationId: project.organizationId,
},
});
if (!dashboard) {
throw new Response("Dashboard not found", { status: 404 });
}
const formData = await request.formData();
const action = formData.get("action");
switch (action) {
case "delete": {
await prisma.metricsDashboard.delete({
where: { id: dashboard.id },
});
return redirectWithSuccessMessage(
v3BuiltInDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
"overview"
),
request,
`Deleted "${dashboard.title}" dashboard`
);
}
case "rename": {
const newTitle = formData.get("title");
if (typeof newTitle !== "string" || newTitle.trim().length === 0) {
throw new Response("Title is required", { status: 400 });
}
await prisma.metricsDashboard.update({
where: { id: dashboard.id },
data: { title: newTitle.trim() },
});
return typedjson({ success: true });
}
default: {
throw new Response("Invalid action", { status: 400 });
}
}
};
export default function Page() {
const {
friendlyId,
title,
layout: dashboardLayout,
defaultPeriod,
queryDefaultQuery,
queryHistory,
isAdmin,
maxRows,
possibleTasks,
widgetCount: initialWidgetCount,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
// Widget limits
const widgetLimitPerDashboard = useWidgetLimitPerDashboard();
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricWidgetsPerDashboard;
const canExceedWidgets = typeof planLimits === "object" && planLimits.canExceed === true;
// Build the action URLs - both use the resource route to avoid full page renders on POST
const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`;
const layoutActionUrl = widgetActionUrl;
// Handle sync errors by showing a toast
const handleSyncError = useCallback((error: Error, action: string) => {
const actionMessages: Record<string, string> = {
add: "Failed to add widget",
update: "Failed to update widget",
delete: "Failed to delete widget",
duplicate: "Failed to duplicate widget",
layout: "Failed to save layout",
};
const message = actionMessages[action] || "Failed to save changes";
toast.custom((t) => (
<ToastUI
variant="error"
message={`${message}. Your changes may not be saved.`}
t={t as string}
title="Sync Error"
/>
));
}, []);
// Add title dialog state
const [showAddTitleDialog, setShowAddTitleDialog] = useState(false);
const [newTitleValue, setNewTitleValue] = useState("");
// Widget limit dialog state (triggered when hook blocks add/duplicate)
const [showWidgetLimitDialog, setShowWidgetLimitDialog] = useState(false);
const handleWidgetLimitReached = useCallback(() => {
setShowWidgetLimitDialog(true);
}, []);
// Use the dashboard editor hook for all state management
const { state, actions } = useDashboardEditor({
initialData: dashboardLayout,
widgetActionUrl,
layoutActionUrl,
widgetLimit: canExceedWidgets ? undefined : widgetLimitPerDashboard,
onSyncError: handleSyncError,
onWidgetLimitReached: handleWidgetLimitReached,
});
// Reactive widget count from editor state (title widgets don't count against limits)
const currentWidgetCount = Object.values(state.widgets).filter(
(w) => w.display.type !== "title"
).length;
const totalWidgetCount = Object.keys(state.widgets).length;
const widgetLimits = { used: currentWidgetCount, limit: widgetLimitPerDashboard };
const widgetIsAtLimit = currentWidgetCount >= widgetLimitPerDashboard;
const widgetLimitRatio =
widgetLimits.limit > 0 ? widgetLimits.used / widgetLimits.limit : widgetLimits.used > 0 ? 1 : 0;
const widgetLimitPercent = Math.min(100, Math.max(0, Math.round(widgetLimitRatio * 100)));
const widgetCanUpgrade = plan?.v3Subscription?.plan && !canExceedWidgets;
// Build the query action URL for the editor
const queryActionUrl = queryPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug }
);
// Handle save from the QueryEditor
const handleSave = useCallback(
(data: QueryEditorSaveData) => {
if (state.editorMode?.type === "add") {
actions.addWidget(data.title, data.query, data.config);
} else if (state.editorMode?.type === "edit") {
actions.updateWidget(state.editorMode.widgetId, data.title, data.query, data.config);
}
},
[state.editorMode, actions]
);
// Render save button for the QueryEditor
const renderSaveForm = useCallback(
(data: QueryEditorSaveData) => {
const isAdd = state.editorMode?.type === "add";
return (
<Button
type="button"
variant="primary/small"
disabled={!data.query}
onClick={() => handleSave(data)}
>
{isAdd ? "Add to dashboard" : "Save changes"}
</Button>
);
},
[state.editorMode, handleSave]
);
// Prepare editor props when in editor mode
const editorProps = state.editorMode
? (() => {
const mode =
state.editorMode.type === "add"
? { type: "dashboard-add" as const, dashboardId: friendlyId, dashboardName: title }
: {
type: "dashboard-edit" as const,
dashboardId: friendlyId,
dashboardName: title,
widgetId: state.editorMode.widgetId,
widgetName: state.editorMode.widget.title,
};
// For edit mode, use the widget's existing values as defaults
const editorDefaultQuery =
state.editorMode.type === "edit" ? state.editorMode.widget.query : queryDefaultQuery;
const editorDefaultChartConfig =
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "chart"
? {
chartType: state.editorMode.widget.display.chartType,
xAxisColumn: state.editorMode.widget.display.xAxisColumn,
yAxisColumns: state.editorMode.widget.display.yAxisColumns,
groupByColumn: state.editorMode.widget.display.groupByColumn,
stacked: state.editorMode.widget.display.stacked,
sortByColumn: state.editorMode.widget.display.sortByColumn,
sortDirection: state.editorMode.widget.display.sortDirection,
aggregation: state.editorMode.widget.display.aggregation,
seriesColors: state.editorMode.widget.display.seriesColors,
}
: defaultChartConfig;
const editorDefaultBigNumberConfig =
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "bignumber"
? {
column: state.editorMode.widget.display.column,
aggregation: state.editorMode.widget.display.aggregation,
sortDirection: state.editorMode.widget.display.sortDirection,
abbreviate: state.editorMode.widget.display.abbreviate,
prefix: state.editorMode.widget.display.prefix,
suffix: state.editorMode.widget.display.suffix,
}
: undefined;
const editorDefaultResultsView =
state.editorMode.type === "edit" ? state.editorMode.widget.display.type : "table";
// Pass the existing result data when editing
const editorDefaultData =
state.editorMode.type === "edit" ? state.editorMode.widget.resultData : undefined;
return {
mode,
editorDefaultQuery,
editorDefaultChartConfig,
editorDefaultBigNumberConfig,
editorDefaultResultsView,
editorDefaultData,
};
})()
: null;
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
<PageAccessories>
{totalWidgetCount > 0 &&
(widgetIsAtLimit ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon}>
Add chart
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this
dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
) : (
<>
<Button
variant="primary/small"
LeadingIcon={IconChartHistogram}
className="pl-1.5"
iconSpacing="gap-x-1.5"
onClick={actions.openAddEditor}
>
Add chart
</Button>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
))}
<Popover>
<PopoverVerticalEllipseTrigger variant="secondary" />
<PopoverContent className="w-fit min-w-[10rem] p-1" align="end">
<div className="flex flex-col gap-1">
<RenameDashboardDialog title={title} />
<DeleteDashboardDialog title={title} />
</div>
</PopoverContent>
</Popover>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1">
{totalWidgetCount === 0 ? (
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={IconChartHistogram}
iconClassName="text-metrics"
panelClassName="max-full"
title="Add your first chart"
accessory={
<Button
variant="primary/small"
LeadingIcon={PlusIcon}
onClick={actions.openAddEditor}
>
Add chart
</Button>
}
>
<Paragraph variant="small">
Charts let you visualize your task metrics. Write a query to pull data from your
runs, then choose how to display it on this dashboard.
</Paragraph>
</InfoPanel>
</MainCenteredContainer>
) : (
<MetricDashboard
key={friendlyId}
layout={state.layout}
widgets={state.widgets}
defaultPeriod={defaultPeriod}
editable={true}
possibleTasks={possibleTasks}
onLayoutChange={actions.updateLayout}
onEditWidget={actions.openEditEditor}
onRenameWidget={actions.renameWidget}
onDeleteWidget={actions.deleteWidget}
onDuplicateWidget={actions.duplicateWidget}
/>
)}
</div>
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
widgetIsAtLimit ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${widgetLimitRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${widgetLimitPercent}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{widgetIsAtLimit ? (
<Header3 className="text-error">
You've used all {widgetLimits.limit} of your available widgets. Upgrade your
plan to enable more.
</Header3>
) : (
<Header3>
You've used {widgetLimits.used}/{widgetLimits.limit} of your charts
</Header3>
)}
{widgetCanUpgrade ? (
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Request more</Button>}
defaultValue="help"
/>
)}
</div>
</div>
</div>
</div>
</PageBody>
{/* Query Editor Sheet - opens on top of the dashboard */}
<Sheet open={!!state.editorMode} onOpenChange={(open) => !open && actions.closeEditor()}>
<SheetContent
side="right"
className="w-[90vw] max-w-none rounded-l-lg border-l border-grid-dimmed p-0 sm:max-w-none"
>
{editorProps && (
<QueryEditor
defaultQuery={editorProps.editorDefaultQuery}
defaultScope="environment"
defaultPeriod={defaultPeriod}
defaultResultsView={
editorProps.editorDefaultResultsView === "chart"
? "graph"
: editorProps.editorDefaultResultsView === "bignumber"
? "bignumber"
: "table"
}
defaultChartConfig={editorProps.editorDefaultChartConfig}
defaultBigNumberConfig={editorProps.editorDefaultBigNumberConfig}
defaultData={editorProps.editorDefaultData}
history={queryHistory}
isAdmin={isAdmin}
maxRows={maxRows}
queryActionUrl={queryActionUrl}
mode={editorProps.mode}
maxPeriodDays={maxPeriodDays}
save={renderSaveForm}
onClose={actions.closeEditor}
/>
)}
</SheetContent>
</Sheet>
{/* Add title dialog */}
<Dialog open={showAddTitleDialog} onOpenChange={setShowAddTitleDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Add title</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (newTitleValue.trim()) {
actions.addTitleWidget(newTitleValue.trim());
setShowAddTitleDialog(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={newTitleValue}
onChange={(e) => setNewTitleValue(e.target.value)}
placeholder="Section title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!newTitleValue.trim()}>
Add
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Widget limit dialog - triggered by hook when add/duplicate is blocked */}
<Dialog open={showWidgetLimitDialog} onOpenChange={setShowWidgetLimitDialog}>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</PageContainer>
);
}
function RenameDashboardDialog({ title }: { title: string }) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const [newTitle, setNewTitle] = useState(title);
const isRenaming =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "rename";
// Close dialog when navigation completes
useEffect(() => {
if (navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
// Sync newTitle state when title changes (after successful rename)
useEffect(() => {
setNewTitle(title);
}, [title]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={IconEdit}
fullWidth
textAlignLeft
className="pl-0.5 pr-3"
leadingIconClassName="gap-x-0"
>
Rename dashboard
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Rename dashboard</DialogHeader>
<Form method="post" className="space-y-4">
<input type="hidden" name="action" value="rename" />
<InputGroup>
<Label>Title</Label>
<Input
name="title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Dashboard title"
required
autoFocus
/>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
disabled={isRenaming || !newTitle.trim()}
>
{isRenaming ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
</Dialog>
);
}
function DeleteDashboardDialog({ title }: { title: string }) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const isDeleting =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
// Close dialog when navigation completes
useEffect(() => {
if (navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
fullWidth
textAlignLeft
>
Delete dashboard
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>Delete dashboard</DialogHeader>
<div className="mb-2 mt-4 flex flex-col gap-2">
<Paragraph>
Are you sure you want to delete <strong>"{title}"</strong>? This action cannot be undone
and all widgets on this dashboard will be permanently removed.
</Paragraph>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<Form method="post">
<Button
type="submit"
name="action"
value="delete"
variant="danger/medium"
LeadingIcon={TrashIcon}
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,8 @@
import { useState } from "react";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { AIQueryInput } from "~/components/code/AIQueryInput";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import type { AITimeFilter } from "./types";
export function AITabContent({
@@ -41,8 +43,8 @@ export function AITabContent({
/>
<div className="pt-4">
<Header3 className="mb-2 text-text-bright">Example prompts</Header3>
<div className="space-y-2">
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
<div className="flex flex-wrap gap-2">
{examplePrompts.map((example) => (
<button
key={example}
@@ -53,9 +55,12 @@ export function AITabContent({
key: (prev?.key ?? 0) + 1,
}));
}}
className="block w-full rounded-md border border-grid-dimmed bg-charcoal-800 px-3 py-2 text-left text-sm text-text-dimmed transition-colors hover:border-grid-bright hover:bg-charcoal-750 hover:text-text-bright"
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500"
>
{example}
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph variant="small" className="text-left transition group-hover:text-text-bright">
{example}
</Paragraph>
</button>
))}
</div>
@@ -63,4 +68,3 @@ export function AITabContent({
</div>
);
}
@@ -38,6 +38,19 @@ ORDER BY p50_duration_ms DESC
LIMIT 20`,
scope: "environment",
},
{
title: "Runs over time",
description:
"Count of runs bucketed over time. The bucket size adjusts automatically to the time range.",
query: `SELECT
timeBucket(),
count() AS run_count
FROM runs
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000`,
scope: "environment",
},
{
title: "Most expensive 100 runs (past 7d)",
description: "Top 100 runs by cost over the last 7 days.",
@@ -1,9 +1,12 @@
import { useState } from "react";
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
import { Button } from "~/components/primitives/Buttons";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { Popover, PopoverTrigger } from "~/components/primitives/Popover";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
import { timeFilterRenderValues } from "~/components/runs/v3/SharedFilters";
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
const SQL_KEYWORDS = [
"SELECT",
@@ -91,23 +94,32 @@ export function QueryHistoryPopover({
<PopoverTrigger asChild>
<Button
type="button"
variant="tertiary/small"
variant="secondary/small"
LeadingIcon={ClockRotateLeftIcon}
leadingIconClassName="-mr-1.5"
TrailingIcon={ChevronUpDownIcon}
disabled={history.length === 0}
>
History
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[400px] min-w-0 overflow-hidden p-0"
<PopoverPrimitive.Content
className={cn(
"z-50 w-[400px] min-w-0 overflow-hidden rounded border border-charcoal-700 bg-background-bright p-0 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
align="start"
sideOffset={6}
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
>
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="max-h-[40rem] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="p-1">
{history.map((item) => {
// Format time filter display
const { valueLabel } = timeFilterRenderValues({ period: item.filterPeriod ?? undefined, from: item.filterFrom ?? undefined, to: item.filterTo ?? undefined });
const { valueLabel } = timeFilterRenderValues({
period: item.filterPeriod ?? undefined,
from: item.filterFrom ?? undefined,
to: item.filterTo ?? undefined,
});
return (
<button
@@ -117,21 +129,16 @@ export function QueryHistoryPopover({
onQuerySelected(item);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900"
className="flex w-full flex-col gap-1 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-750"
>
<div className="flex flex-1 flex-col items-start gap-0.5 overflow-hidden">
<div className="flex w-full flex-col items-start">
{item.title ? (
<>
<p className="w-full truncate text-left text-sm font-medium text-text-bright">
{item.title}
</p>
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
{highlightSQL(item.query)}
</p>
</>
<p className="mb-1 truncate text-left text-sm font-medium text-text-bright">
{item.title}
</p>
) : (
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-[#9b99ff]">
{highlightSQL(item.query)}
<p className="mb-1 truncate text-left font-mono text-xs text-text-bright">
{item.query.split("\n")[0].slice(0, 60)}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
@@ -140,13 +147,17 @@ export function QueryHistoryPopover({
{item.userName && <span>· {item.userName}</span>}
</div>
</div>
<div className="w-full border-l-2 border-charcoal-600 pl-2.5">
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
{highlightSQL(item.query)}
</p>
</div>
</button>
);
})}
</div>
</div>
</PopoverContent>
</PopoverPrimitive.Content>
</Popover>
);
}
@@ -488,6 +488,11 @@ ORDER BY run_count DESC`,
<FunctionCategory
title="Date/time functions"
functions={[
{
name: "timeBucket()",
desc: "Auto-bucket by time period. Uses the table's time column with an interval based on the query's time range.",
example: "SELECT timeBucket(), count() FROM runs GROUP BY timeBucket",
},
{ name: "now()", desc: "Current date and time", example: "now()" },
{ name: "today()", desc: "Current date", example: "today()" },
{ name: "yesterday()", desc: "Yesterday's date", example: "yesterday()" },
@@ -3,6 +3,7 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { prisma } from "~/db.server";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
@@ -87,9 +88,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
firstDayOfNextMonth.setUTCDate(1);
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
const [plan, usage] = await Promise.all([
const [plan, usage, customDashboards] = await Promise.all([
getCurrentPlan(organization.id),
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
prisma.metricsDashboard.findMany({
where: { organizationId: organization.id },
select: {
friendlyId: true,
title: true,
layout: true,
},
orderBy: { createdAt: "desc" },
}),
]);
let hasExceededFreeTier = false;
@@ -99,6 +109,39 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
usagePercentage = usage.cents / plan.v3Subscription.plan.limits.includedUsage;
}
// Derive metric dashboard limit from plan, fallback to 3
const metricDashboardsLimitValue = plan?.v3Subscription?.plan?.limits?.metricDashboards;
const dashboardLimit =
typeof metricDashboardsLimitValue === "number"
? metricDashboardsLimitValue
: metricDashboardsLimitValue?.number ?? 3;
// Derive widget-per-dashboard limit from plan, fallback to 16
const metricWidgetsLimitValue = plan?.v3Subscription?.plan?.limits?.metricWidgetsPerDashboard;
const widgetLimitPerDashboard =
typeof metricWidgetsLimitValue === "number"
? metricWidgetsLimitValue
: metricWidgetsLimitValue?.number ?? 16;
// Compute widget counts per dashboard from layout JSON
const customDashboardsWithWidgetCount = customDashboards.map((d) => {
let widgetCount = 0;
try {
const layout = JSON.parse(String(d.layout)) as Record<string, unknown>;
const widgets = layout.widgets;
if (widgets && typeof widgets === "object") {
widgetCount = Object.keys(widgets as Record<string, unknown>).length;
}
} catch {
// ignore parse errors
}
return {
friendlyId: d.friendlyId,
title: d.title,
widgetCount,
};
});
return typedjson({
organizations,
organization,
@@ -106,6 +149,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
environment,
isImpersonating: !!impersonationId,
currentPlan: { ...plan, v3Usage: { ...usage, hasExceededFreeTier, usagePercentage } },
customDashboards: customDashboardsWithWidgetCount,
dashboardLimits: {
used: customDashboards.length,
limit: dashboardLimit,
},
widgetLimitPerDashboard,
});
};
+283
View File
@@ -0,0 +1,283 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { requireUserId } from "~/services/session.server";
import { hasAccessToEnvironment } from "~/models/runtimeEnvironment.server";
import { executeQuery } from "~/services/queryService.server";
import {
QueryWidget,
type QueryWidgetConfig,
type QueryWidgetData,
} from "~/components/metrics/QueryWidget";
import { useElementVisibility } from "~/hooks/useElementVisibility";
import { useInterval } from "~/hooks/useInterval";
const Scope = z.union([z.literal("environment"), z.literal("organization"), z.literal("project")]);
// Response type for the action
type MetricWidgetActionResponse =
| { success: false; error: string }
| {
success: true;
data: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
stats: { elapsed_ns: string } | null;
hiddenColumns: string[] | null;
reachedMaxRows: boolean;
periodClipped: number | null;
maxQueryPeriod: number | undefined;
timeRange: { from: string; to: string };
};
};
const MetricWidgetQuery = z.object({
query: z.string(),
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
scope: Scope,
period: z.string().nullable(),
from: z.string().nullable(),
to: z.string().nullable(),
taskIdentifiers: z.array(z.string()).optional(),
queues: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
});
export const action = async ({ request }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const data = await request.json();
const submission = MetricWidgetQuery.safeParse(data);
if (!submission.success) {
return json(
{
success: false as const,
error: "Invalid input",
},
{ status: 400 }
);
}
const {
query,
organizationId,
projectId,
environmentId,
scope,
period,
from,
to,
taskIdentifiers,
queues,
tags,
} = submission.data;
// Check they should be able to access it
const hasAccess = await hasAccessToEnvironment({
environmentId,
projectId,
organizationId,
userId,
});
if (!hasAccess) {
return json(
{
success: false as const,
error: "You don't have permission for this resource",
},
{ status: 400 }
);
}
const queryResult = await executeQuery({
name: "query-page",
query,
scope,
organizationId,
projectId,
environmentId,
period,
from,
to,
taskIdentifiers,
queues,
// Set higher concurrency if many widgets are on screen at once
customOrgConcurrencyLimit: 15,
});
if (!queryResult.success) {
return json(
{
success: false as const,
error: queryResult.error.message,
},
{ status: 400 }
);
}
return json({
success: true as const,
data: {
rows: queryResult.result.rows,
columns: queryResult.result.columns,
stats: queryResult.result.stats,
hiddenColumns: queryResult.result.hiddenColumns ?? null,
reachedMaxRows: queryResult.result.reachedMaxRows,
periodClipped: queryResult.periodClipped,
maxQueryPeriod: queryResult.maxQueryPeriod,
timeRange: {
from: queryResult.timeRange.from.toISOString(),
to: queryResult.timeRange.to.toISOString(),
},
},
});
};
type MetricWidgetProps = {
/** Unique key for this widget - used to identify the fetcher */
widgetKey: string;
title: string;
config: QueryWidgetConfig;
refreshIntervalMs?: number;
isResizing?: boolean;
isDraggable?: boolean;
/** Callback when edit button is clicked - receives current data */
onEdit?: (data: QueryWidgetData) => void;
/** Callback when rename is clicked - receives new title */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked */
onDelete?: () => void;
/** Callback when duplicate is clicked - receives current data */
onDuplicate?: (data: QueryWidgetData) => void;
} & z.infer<typeof MetricWidgetQuery>;
export function MetricWidget({
widgetKey,
title,
config,
refreshIntervalMs,
isResizing,
isDraggable,
onEdit,
onRename,
onDelete,
onDuplicate,
...props
}: MetricWidgetProps) {
const [response, setResponse] = useState<MetricWidgetActionResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
// Track the latest props so the submit callback always uses fresh values
// without needing to be recreated (which would cause useInterval to re-register listeners).
const propsRef = useRef(props);
propsRef.current = props;
const submit = useCallback(() => {
// Skip fetching if the widget is not visible on screen
if (!isVisibleRef.current) return;
// Abort any in-flight request for this widget
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsLoading(true);
fetch(`/resources/metric`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(propsRef.current),
signal: controller.signal,
})
.then(async (res) => {
try {
return (await res.json()) as MetricWidgetActionResponse;
} catch {
throw new Error(`Request failed (${res.status})`);
}
})
.then((data) => {
if (!controller.signal.aborted) {
setResponse(data);
setIsLoading(false);
}
})
.catch((err) => {
if (err instanceof DOMException && err.name === "AbortError") return;
if (!controller.signal.aborted) {
// Only surface the error if there's no existing successful data to preserve
setResponse((prev) =>
prev?.success ? prev : { success: false, error: err.message || "Network error" }
);
setIsLoading(false);
}
});
}, []);
// Track visibility so we only fetch for on-screen widgets.
// When a widget scrolls into view and has no data yet, trigger a load.
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
onVisibilityChange: (visible) => {
if (visible && !response) {
submit();
}
},
});
// Clean up on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
// Reload periodically and on focus (onLoad: false — the useEffect below handles initial load)
useInterval({ interval: refreshIntervalMs, callback: submit, onLoad: false });
// Reload on mount and when query, time period, or filters change
useEffect(() => {
submit();
}, [
submit,
props.query,
props.from,
props.to,
props.period,
props.scope,
JSON.stringify(props.taskIdentifiers),
JSON.stringify(props.queues),
]);
const data = response?.success
? { rows: response.data.rows, columns: response.data.columns }
: { rows: [], columns: [] };
const timeRange = response?.success ? response.data.timeRange : undefined;
return (
<div ref={visibilityRef} className="h-full">
<QueryWidget
title={title}
titleString={title}
query={props.query}
config={config}
isLoading={isLoading}
data={data}
timeRange={timeRange}
error={response?.success === false ? response.error : undefined}
isResizing={isResizing}
isDraggable={isDraggable}
onEdit={onEdit}
onRename={onRename}
onDelete={onDelete}
onDuplicate={onDuplicate}
/>
</div>
);
}
@@ -0,0 +1,492 @@
import { type ActionFunctionArgs } from "@remix-run/node";
import { nanoid } from "nanoid";
import { z } from "zod";
import { typedjson } from "remix-typedjson";
import { prisma } from "~/db.server";
import { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
DashboardLayout,
LayoutItem,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
// Schemas for each action type
const AddWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required").nullish(),
title: z.string().min(1, "Title is required"),
query: z.string().default(""),
config: z.string().transform((str, ctx) => {
try {
const parsed = JSON.parse(str);
const result = QueryWidgetConfig.safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid widget config",
});
return z.NEVER;
}
return result.data;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON",
});
return z.NEVER;
}
}),
});
const UpdateWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required"),
title: z.string().min(1, "Title is required"),
query: z.string().min(1, "Query is required"),
config: z.string().transform((str, ctx) => {
try {
const parsed = JSON.parse(str);
const result = QueryWidgetConfig.safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid widget config",
});
return z.NEVER;
}
return result.data;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON",
});
return z.NEVER;
}
}),
});
const RenameWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required"),
title: z.string().min(1, "Title is required"),
});
const DeleteWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required"),
});
const DuplicateWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required"),
newId: z.string().min(1, "New widget ID is required").nullish(),
});
const SaveLayoutSchema = z.object({
layout: z.string().transform((str, ctx) => {
try {
const parsed = JSON.parse(str);
const result = z.array(LayoutItem).safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid layout format",
});
return z.NEVER;
}
return result.data;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON",
});
return z.NEVER;
}
}),
});
const ParamsSchema = EnvironmentParamSchema.extend({
dashboardId: z.string(),
});
// Check widget limit for add/duplicate actions (title widgets don't count)
async function checkWidgetLimit(
existingLayout: z.infer<typeof DashboardLayout>,
organizationId: string
) {
const currentWidgetCount = Object.values(existingLayout.widgets).filter(
(w) => w.display.type !== "title"
).length;
const plan = await getCurrentPlan(organizationId);
const metricWidgetsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
?.metricWidgetsPerDashboard;
const widgetLimit =
typeof metricWidgetsLimitValue === "number"
? metricWidgetsLimitValue
: (metricWidgetsLimitValue?.number ?? 16);
if (currentWidgetCount >= widgetLimit) {
throw new Response("Widget limit reached", { status: 403 });
}
}
// Optimistic concurrency save: uses updateMany so we can include updatedAt
// in the where clause. If another request modified the dashboard between our
// read and this write, updatedAt won't match and count will be 0.
async function saveDashboardLayout(
dashboardId: string,
expectedUpdatedAt: Date,
updatedLayout: z.infer<typeof DashboardLayout>
) {
const result = await prisma.metricsDashboard.updateMany({
where: { id: dashboardId, updatedAt: expectedUpdatedAt },
data: {
layout: JSON.stringify(updatedLayout),
},
});
if (result.count === 0) {
throw new Response(
"Dashboard was modified by another request. Please refresh and try again.",
{ status: 409 }
);
}
}
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, dashboardId } = ParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
// Load the dashboard
const dashboard = await prisma.metricsDashboard.findFirst({
where: {
friendlyId: dashboardId,
organizationId: project.organizationId,
},
});
if (!dashboard) {
throw new Response("Dashboard not found", { status: 404 });
}
const formData = await request.formData();
const action = formData.get("action");
// Parse existing layout (shared across all actions)
let existingLayout: z.infer<typeof DashboardLayout>;
try {
const parsed = JSON.parse(dashboard.layout);
const layoutResult = DashboardLayout.safeParse(parsed);
if (!layoutResult.success) {
// For add action, we can start with empty layout
if (action === "add") {
existingLayout = {
version: "1",
layout: [],
widgets: {},
};
} else {
throw new Response("Invalid dashboard layout", { status: 500 });
}
} else {
existingLayout = layoutResult.data;
}
} catch (e) {
if (e instanceof Response) throw e;
if (action === "add") {
existingLayout = {
version: "1",
layout: [],
widgets: {},
};
} else {
throw new Response("Failed to parse dashboard layout", { status: 500 });
}
}
switch (action) {
case "add": {
const rawData = {
widgetId: formData.get("widgetId"),
title: formData.get("title"),
query: formData.get("query"),
config: formData.get("config"),
};
const result = AddWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { title, query, config } = result.data;
// Validate that non-title widgets have a query
if (config.type !== "title" && !query) {
throw new Response("Query is required for chart widgets", { status: 400 });
}
// Title widgets don't count against the limit
if (config.type !== "title") {
await checkWidgetLimit(existingLayout, project.organizationId);
}
// Use client-provided widget ID if available, otherwise generate one
// Using the client's ID ensures optimistic UI state stays in sync with the server
const widgetId = result.data.widgetId || nanoid(8);
// Calculate position at the bottom
let maxBottom = 0;
for (const item of existingLayout.layout) {
const itemBottom = item.y + item.h;
if (itemBottom > maxBottom) {
maxBottom = itemBottom;
}
}
// Add new layout item (full width, height depends on widget type)
const newLayoutItem = {
i: widgetId,
x: 0,
y: maxBottom,
w: 12,
h: config.type === "title" ? 2 : 15,
};
// Add new widget
const newWidget = {
title,
query,
display: config,
};
// Update the layout
const updatedLayout = {
...existingLayout,
layout: [...existingLayout.layout, newLayoutItem],
widgets: {
...existingLayout.widgets,
[widgetId]: newWidget,
},
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, widgetId });
}
case "update": {
const rawData = {
widgetId: formData.get("widgetId"),
title: formData.get("title"),
query: formData.get("query"),
config: formData.get("config"),
};
const result = UpdateWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { widgetId, title, query, config } = result.data;
// Check if widget exists
if (!existingLayout.widgets[widgetId]) {
throw new Response("Widget not found", { status: 404 });
}
// Update the widget
const updatedWidget = {
title,
query,
display: config,
};
// Update the layout
const updatedLayout = {
...existingLayout,
widgets: {
...existingLayout.widgets,
[widgetId]: updatedWidget,
},
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, updatedTitle: title });
}
case "rename": {
const rawData = {
widgetId: formData.get("widgetId"),
title: formData.get("title"),
};
const result = RenameWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { widgetId, title } = result.data;
// Check if widget exists
if (!existingLayout.widgets[widgetId]) {
throw new Response("Widget not found", { status: 404 });
}
// Update just the title
const updatedLayout = {
...existingLayout,
widgets: {
...existingLayout.widgets,
[widgetId]: {
...existingLayout.widgets[widgetId],
title,
},
},
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, renamedTitle: title });
}
case "delete": {
const rawData = {
widgetId: formData.get("widgetId"),
};
const result = DeleteWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { widgetId } = result.data;
// Get widget title before deleting (for the success message)
const widget = existingLayout.widgets[widgetId];
const widgetTitle = widget?.title ?? "Widget";
// Remove widget from layout and widgets
const updatedLayout = {
...existingLayout,
layout: existingLayout.layout.filter((item) => item.i !== widgetId),
widgets: Object.fromEntries(
Object.entries(existingLayout.widgets).filter(([key]) => key !== widgetId)
),
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, deletedTitle: widgetTitle });
}
case "duplicate": {
await checkWidgetLimit(existingLayout, project.organizationId);
const rawData = {
widgetId: formData.get("widgetId"),
newId: formData.get("newId"),
};
const result = DuplicateWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { widgetId } = result.data;
// Find the original widget
const originalWidget = existingLayout.widgets[widgetId];
if (!originalWidget) {
throw new Response("Widget not found", { status: 404 });
}
// Find the original layout item
const originalLayoutItem = existingLayout.layout.find((item) => item.i === widgetId);
if (!originalLayoutItem) {
throw new Response("Widget layout not found", { status: 404 });
}
// Use client-provided ID if available, otherwise generate one
// Using the client's ID ensures optimistic UI state stays in sync with the server
const newWidgetId = result.data.newId || nanoid(8);
// Calculate position at the bottom
let maxBottom = 0;
for (const item of existingLayout.layout) {
const itemBottom = item.y + item.h;
if (itemBottom > maxBottom) {
maxBottom = itemBottom;
}
}
// Create new layout item with same dimensions but at the bottom
const newLayoutItem = {
i: newWidgetId,
x: 0,
y: maxBottom,
w: originalLayoutItem.w,
h: originalLayoutItem.h,
};
// Create new widget with "(Copy)" suffix
const newWidget = {
...originalWidget,
title: `${originalWidget.title} (Copy)`,
};
// Update the layout
const updatedLayout = {
...existingLayout,
layout: [...existingLayout.layout, newLayoutItem],
widgets: {
...existingLayout.widgets,
[newWidgetId]: newWidget,
},
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, duplicatedTitle: originalWidget.title });
}
case "layout": {
const result = SaveLayoutSchema.safeParse({
layout: formData.get("layout"),
});
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
// Update layout positions while preserving widgets
const updatedLayout = {
...existingLayout,
layout: result.data.layout,
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true });
}
default: {
throw new Response("Invalid action", { status: 400 });
}
}
};
@@ -0,0 +1,84 @@
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { findProjectBySlug } from "~/models/project.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
const CreateDashboardSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional().default(""),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Check dashboard limit
const [plan, existingCount] = await Promise.all([
getCurrentPlan(project.organizationId),
prisma.metricsDashboard.count({
where: { organizationId: project.organizationId },
}),
]);
const metricDashboardsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
?.metricDashboards;
const dashboardLimit =
typeof metricDashboardsLimitValue === "number"
? metricDashboardsLimitValue
: (metricDashboardsLimitValue?.number ?? 3);
if (existingCount >= dashboardLimit) {
throw new Response("Dashboard limit reached", { status: 403 });
}
const formData = await request.formData();
const rawData = {
title: formData.get("title"),
description: formData.get("description") ?? "",
};
const result = CreateDashboardSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data", { status: 400 });
}
const { title, description } = result.data;
// Create empty default layout
const defaultLayout = JSON.stringify({
version: "1",
layout: [],
widgets: {},
});
const dashboard = await prisma.metricsDashboard.create({
data: {
friendlyId: generateFriendlyId("dashboard"),
title,
description,
organizationId: project.organizationId,
projectId: project.id,
ownerId: userId,
layout: defaultLayout,
},
});
// Redirect to the new dashboard
return redirect(
v3CustomDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: dashboard.friendlyId }
)
);
};
@@ -11,7 +11,7 @@ import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { uiComponent } from "@team-plain/typescript-sdk";
import { GitHubLightIcon } from "@trigger.dev/companyicons";
import {
AddOnPricing,
type AddOnPricing,
type FreePlanDefinition,
type Limits,
type PaidPlanDefinition,
@@ -225,6 +225,18 @@ const pricingDefinitions = {
title: "Additional branches",
content: "Then $10/month per branch",
},
metricDashboards: {
title: "Custom dashboards",
content: "Custom metric dashboards for monitoring and visualizing your task data.",
},
additionalDashboards: {
title: "Additional dashboards",
content: "Then $10/month per dashboard",
},
queryPeriod: {
title: "Query period",
content: "The maximum number of days a query can look back when analyzing your task data.",
},
};
type PricingPlansProps = {
@@ -534,8 +546,10 @@ export function TierFree({
<TeamMembers limits={plan.limits} />
<Environments limits={plan.limits} />
<Branches limits={plan.limits} />
<MetricDashboards limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<QueryPeriod limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits} />
@@ -651,8 +665,10 @@ export function TierHobby({
<TeamMembers limits={plan.limits} />
<Environments limits={plan.limits} />
<Branches limits={plan.limits} />
<MetricDashboards limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<QueryPeriod limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits} />
@@ -774,8 +790,12 @@ export function TierPro({
<TeamMembers limits={plan.limits}>{pricingDefinitions.additionalSeats.content}</TeamMembers>
<Environments limits={plan.limits} />
<Branches limits={plan.limits}>{pricingDefinitions.additionalBranches.content}</Branches>
<MetricDashboards limits={plan.limits}>
{pricingDefinitions.additionalDashboards.content}
</MetricDashboards>
<Schedules limits={plan.limits}>{pricingDefinitions.additionalSchedules.content}</Schedules>
<LogRetention limits={plan.limits} />
<QueryPeriod limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConcurrency limits={plan.limits}>
@@ -1139,6 +1159,53 @@ function RealtimeConcurrency({ limits, children }: { limits: Limits; children?:
);
}
function MetricDashboards({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
if (limits.metricDashboards.number === 0) {
return (
<FeatureItem>
<DefinitionTip
title={pricingDefinitions.metricDashboards.title}
content={pricingDefinitions.metricDashboards.content}
>
Custom dashboards
</DefinitionTip>
</FeatureItem>
);
}
return (
<FeatureItem checked>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-1">
{limits.metricDashboards.number}
{limits.metricDashboards.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.metricDashboards.title}
content={pricingDefinitions.metricDashboards.content}
>
custom {limits.metricDashboards.number === 1 ? "dashboard" : "dashboards"}
</DefinitionTip>
</div>
{children && <span className="text-xs text-text-dimmed">{children}</span>}
</div>
</FeatureItem>
);
}
function QueryPeriod({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.queryPeriodDays.number} {limits.queryPeriodDays.number === 1 ? "day" : "days"}{" "}
<DefinitionTip
title={pricingDefinitions.queryPeriod.title}
content={pricingDefinitions.queryPeriod.content}
>
query period
</DefinitionTip>
</FeatureItem>
);
}
function Branches({ limits, children }: { limits: Limits; children?: React.ReactNode }) {
return (
<FeatureItem checked={limits.branches.number > 0}>
@@ -1,6 +1,13 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
import {
SideMenuSectionIdSchema,
type SideMenuSectionId,
} from "~/components/navigation/sideMenuTypes";
import {
updateItemOrder,
updateSideMenuPreferences,
} from "~/services/dashboardPreferences.server";
import { requireUser } from "~/services/session.server";
// Transforms form data string "true"/"false" to boolean, or undefined if not present
@@ -11,7 +18,12 @@ const booleanFromFormData = z
const RequestSchema = z.object({
isCollapsed: booleanFromFormData,
manageSectionCollapsed: booleanFromFormData,
sectionId: SideMenuSectionIdSchema.optional(),
sectionCollapsed: booleanFromFormData,
// Generic item order fields
organizationId: z.string().optional(),
listId: z.string().optional(),
itemOrder: z.string().optional(), // JSON-encoded string[]
});
export async function action({ request }: ActionFunctionArgs) {
@@ -25,10 +37,39 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ success: false, error: "Invalid request data" }, { status: 400 });
}
// Handle item order update
if (result.data.organizationId && result.data.listId && result.data.itemOrder) {
let parsed: unknown;
try {
parsed = JSON.parse(result.data.itemOrder);
} catch {
parsed = [];
}
const orderResult = z.array(z.string()).safeParse(parsed);
if (orderResult.success) {
await updateItemOrder({
user,
organizationId: result.data.organizationId,
listId: result.data.listId,
order: orderResult.data,
});
}
return json({ success: true });
}
// Build sectionCollapsed parameter if both sectionId and sectionCollapsed are provided
const sectionCollapsed =
result.data.sectionId !== undefined && result.data.sectionCollapsed !== undefined
? {
sectionId: result.data.sectionId as SideMenuSectionId,
collapsed: result.data.sectionCollapsed,
}
: undefined;
await updateSideMenuPreferences({
user,
isCollapsed: result.data.isCollapsed,
manageSectionCollapsed: result.data.manageSectionCollapsed,
sectionCollapsed,
});
return json({ success: true });
@@ -2,7 +2,7 @@ import { ArrowTrendingUpIcon } from "@heroicons/react/20/solid";
import { IconTimeline } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { Button } from "~/components/primitives/Buttons";
import { BigNumber } from "~/components/primitives/charts/BigNumber";
import { BigNumberCard } from "~/components/primitives/charts/BigNumberCard";
import { Card } from "~/components/primitives/charts/Card";
import { type ChartConfig, type ChartState } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
@@ -256,7 +256,11 @@ function ChartsDashboard() {
</Card.Accessory>
</Card.Header>
<Card.Content>
<BigNumber value={101} suffix="USD" />
<BigNumberCard
rows={[{ amount: 101 }]}
columns={[{ name: "amount", type: "Float64" }]}
config={{ column: "amount", aggregation: "sum", abbreviate: true }}
/>
</Card.Content>
</Card>
</div>
@@ -16,7 +16,8 @@ function initializeClickhouseClient() {
const logsQuerySettings = {
list: {
max_memory_usage: env.CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE.toString(),
max_bytes_before_external_sort: env.CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT.toString(),
max_bytes_before_external_sort:
env.CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT.toString(),
max_threads: env.CLICKHOUSE_LOGS_LIST_MAX_THREADS,
...(env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ && {
max_rows_to_read: env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ.toString(),
@@ -51,3 +52,30 @@ function initializeClickhouseClient() {
return clickhouse;
}
export const queryClickhouseClient = singleton(
"queryClickhouseClient",
initializeQueryClickhouseClient
);
function initializeQueryClickhouseClient() {
if (!env.QUERY_CLICKHOUSE_URL) {
throw new Error("QUERY_CLICKHOUSE_URL is not set");
}
const url = new URL(env.QUERY_CLICKHOUSE_URL);
return new ClickHouse({
url: url.toString(),
name: "query-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: {
request: true,
},
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
@@ -5,11 +5,24 @@ import { type UserFromSession } from "./session.server";
const SideMenuPreferences = z.object({
isCollapsed: z.boolean().default(false),
manageSectionCollapsed: z.boolean().default(false),
// Map for section collapsed states - keys are section identifiers
collapsedSections: z.record(z.string(), z.boolean()).optional(),
/** Organization-specific settings */
organizations: z
.record(
z.string(),
z.object({
orderedItems: z.record(z.string(), z.array(z.string())),
})
)
.optional(),
});
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
import { type SideMenuSectionId } from "~/components/navigation/sideMenuTypes";
export type { SideMenuSectionId };
const DashboardPreferences = z.object({
version: z.literal("1"),
currentProjectId: z.string().optional(),
@@ -111,11 +124,12 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
export async function updateSideMenuPreferences({
user,
isCollapsed,
manageSectionCollapsed,
sectionCollapsed,
}: {
user: UserFromSession;
isCollapsed?: boolean;
manageSectionCollapsed?: boolean;
/** Update a specific section's collapsed state */
sectionCollapsed?: { sectionId: SideMenuSectionId; collapsed: boolean };
}) {
if (user.isImpersonating) {
return;
@@ -123,17 +137,26 @@ export async function updateSideMenuPreferences({
// Parse with schema to apply defaults, then overlay any new values
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
// Build the updated collapsedSections map
let updatedCollapsedSections = { ...currentSideMenu.collapsedSections };
if (sectionCollapsed) {
updatedCollapsedSections[sectionCollapsed.sectionId] = sectionCollapsed.collapsed;
}
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
...(isCollapsed !== undefined && { isCollapsed }),
...(manageSectionCollapsed !== undefined && { manageSectionCollapsed }),
collapsedSections: updatedCollapsedSections,
});
// Only update if something changed
if (
updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
updatedSideMenu.manageSectionCollapsed === currentSideMenu.manageSectionCollapsed
) {
const hasCollapsedSectionsChanged =
JSON.stringify(updatedSideMenu.collapsedSections) !==
JSON.stringify(currentSideMenu.collapsedSections);
if (updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && !hasCollapsedSectionsChanged) {
return;
}
@@ -151,3 +174,59 @@ export async function updateSideMenuPreferences({
},
});
}
/** Get the stored item order for a specific list within an organization */
export function getItemOrder(
sideMenu: SideMenuPreferences | undefined,
organizationId: string,
listId: string
): string[] | undefined {
return sideMenu?.organizations?.[organizationId]?.orderedItems?.[listId];
}
export async function updateItemOrder({
user,
organizationId,
listId,
order,
}: {
user: UserFromSession;
organizationId: string;
listId: string;
order: string[];
}) {
if (user.isImpersonating) {
return;
}
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
const currentOrg = currentSideMenu.organizations?.[organizationId];
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
organizations: {
...currentSideMenu.organizations,
[organizationId]: {
...currentOrg,
orderedItems: {
...currentOrg?.orderedItems,
[listId]: order,
},
},
},
});
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
sideMenu: updatedSideMenu,
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
});
}
+150 -44
View File
@@ -8,19 +8,22 @@ import {
} from "@internal/clickhouse";
import type { CustomerQuerySource } from "@trigger.dev/database";
import type { TableSchema, WhereClauseCondition } from "@internal/tsql";
import { type z } from "zod";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { clickhouseClient } from "./clickhouseInstance.server";
import { queryClickhouseClient } from "./clickhouseInstance.server";
import {
queryConcurrencyLimiter,
DEFAULT_ORG_CONCURRENCY_LIMIT,
GLOBAL_CONCURRENCY_LIMIT,
} from "./queryConcurrencyLimiter.server";
import { getLimit } from "./platform.v3.server";
import { timeFilters, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import parse from "parse-duration";
import { querySchemas, QueryScopeSchema, type QueryScope } from "~/v3/querySchemas";
export type { TableSchema, TSQLQueryResult };
export type QueryScope = "organization" | "project" | "environment";
export { QueryScopeSchema };
export type { TableSchema, TSQLQueryResult, QueryScope };
const scopeToEnum = {
organization: "ORGANIZATION",
@@ -56,14 +59,20 @@ function getDefaultClickhouseSettings(): ClickHouseSettings {
export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
ExecuteTSQLOptions<TOut>,
"tableSchema" | "fieldMappings"
"tableSchema" | "fieldMappings" | "enforcedWhereClause" | "whereClauseFallback" | "schema"
> & {
organizationId: string;
projectId?: string;
environmentId?: string;
tableSchema: TableSchema[];
projectId: string;
environmentId: string;
/** The scope of the query - determines tenant isolation */
scope: QueryScope;
period?: string | null;
from?: string | null;
to?: string | null;
/** Filter to specific task identifiers */
taskIdentifiers?: string[];
/** Filter to specific queues */
queues?: string[];
/** History options for saving query to billing/audit */
history?: {
/** Where the query originated from */
@@ -72,15 +81,6 @@ export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
userId?: string | null;
/** Skip saving to history (e.g., when impersonating) */
skip?: boolean;
/** Time filter settings to save with the query */
timeFilter?: {
/** Period like "7d", "24h", etc. */
period?: string;
/** Custom start date */
from?: Date;
/** Custom end date */
to?: Date;
};
};
/** Custom per-org concurrency limit (overrides default) */
customOrgConcurrencyLimit?: number;
@@ -90,8 +90,24 @@ export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
* Extended result type that includes the optional queryId when saved to history
*/
export type ExecuteQueryResult<T> =
| [error: Error, result: null, queryId: null]
| [error: null, result: T, queryId: string | null];
| {
success: true;
result: T;
queryId: string | null;
periodClipped: number | null;
maxQueryPeriod: number;
timeRange: { from: Date; to: Date };
}
| { success: false; error: Error };
export async function getDefaultPeriod(organizationId: string): Promise<string> {
const idealDefaultPeriodDays = 7;
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
if (maxQueryPeriod < idealDefaultPeriodDays) {
return `${maxQueryPeriod}d`;
}
return `${idealDefaultPeriodDays}d`;
}
/**
* Execute a TSQL query against ClickHouse with tenant isolation
@@ -102,14 +118,17 @@ export async function executeQuery<TOut extends z.ZodSchema>(
options: ExecuteQueryOptions<TOut>
): Promise<ExecuteQueryResult<Exclude<TSQLQueryResult<z.output<TOut>>[1], null>>> {
const {
period,
from,
to,
scope,
organizationId,
projectId,
environmentId,
enforcedWhereClause,
taskIdentifiers,
queues,
history,
customOrgConcurrencyLimit,
whereClauseFallback,
...baseOptions
} = options;
@@ -118,20 +137,81 @@ export async function executeQuery<TOut extends z.ZodSchema>(
const orgLimit = customOrgConcurrencyLimit ?? DEFAULT_ORG_CONCURRENCY_LIMIT;
// Acquire concurrency slot
const acquireResult = await queryConcurrencyLimiter.acquire({
key: organizationId,
requestId,
keyLimit: orgLimit,
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
});
const acquireResult = await queryConcurrencyLimiter.acquire({
key: organizationId,
requestId,
keyLimit: orgLimit,
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
});
if (!acquireResult.success) {
const errorMessage =
acquireResult.reason === "key_limit"
? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.`
: "We're experiencing a lot of queries at the moment. Please try again later.";
return [new QueryError(errorMessage, { query: options.query }), null, null];
}
if (!acquireResult.success) {
const errorMessage =
acquireResult.reason === "key_limit"
? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.`
: "We're experiencing a lot of queries at the moment. Please try again later.";
return { success: false, error: new QueryError(errorMessage, { query: options.query }) };
}
// Build time filter fallback for triggered_at column
const defaultPeriod = await getDefaultPeriod(organizationId);
const timeFilter = timeFilters({
period: period ?? undefined,
from: from ?? undefined,
to: to ?? undefined,
defaultPeriod,
});
// Calculate the effective "from" date the user is requesting (for period clipping check)
// This is null only when the user specifies just a "to" date (rare case)
let requestedFromDate: Date | null = null;
if (timeFilter.from) {
requestedFromDate = new Date(timeFilter.from);
} else if (!timeFilter.to) {
// Period specified (or default) - calculate from now
const periodMs = parse(timeFilter.period ?? defaultPeriod) ?? 7 * 24 * 60 * 60 * 1000;
requestedFromDate = new Date(Date.now() - periodMs);
}
// Build the fallback WHERE condition based on what the user specified
let triggeredAtFallback: WhereClauseCondition;
if (timeFilter.from && timeFilter.to) {
triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to };
} else if (timeFilter.from) {
triggeredAtFallback = { op: "gte", value: timeFilter.from };
} else if (timeFilter.to) {
triggeredAtFallback = { op: "lte", value: timeFilter.to };
} else {
triggeredAtFallback = { op: "gte", value: requestedFromDate! };
}
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
const maxQueryPeriodDate = new Date(Date.now() - maxQueryPeriod * 24 * 60 * 60 * 1000);
// Check if the requested time period exceeds the plan limit
const periodClipped = requestedFromDate !== null && requestedFromDate < maxQueryPeriodDate;
// Force tenant isolation and time period limits
const enforcedWhereClause = {
organization_id: { op: "eq", value: organizationId },
project_id:
scope === "project" || scope === "environment" ? { op: "eq", value: projectId } : undefined,
environment_id: scope === "environment" ? { op: "eq", value: environmentId } : undefined,
triggered_at: { op: "gte", value: maxQueryPeriodDate },
// Optional filters for tasks and queues
task_identifier:
taskIdentifiers && taskIdentifiers.length > 0
? { op: "in", values: taskIdentifiers }
: undefined,
queue: queues && queues.length > 0 ? { op: "in", values: queues } : undefined,
} satisfies Record<string, WhereClauseCondition | undefined>;
// Compute the effective time range for timeBucket() interval calculation
const timeRange = timeFilterFromTo({
period: period ?? undefined,
from: from ?? undefined,
to: to ?? undefined,
defaultPeriod,
});
try {
// Build field mappings for project_ref → project_id and environment_id → slug translation
@@ -150,11 +230,17 @@ export async function executeQuery<TOut extends z.ZodSchema>(
environment: Object.fromEntries(environments.map((e) => [e.id, e.slug])),
};
const result = await executeTSQL(clickhouseClient.reader, {
const result = await executeTSQL(queryClickhouseClient.reader, {
...baseOptions,
schema: z.record(z.any()),
tableSchema: querySchemas,
transformValues: true,
enforcedWhereClause,
fieldMappings,
whereClauseFallback,
whereClauseFallback: {
triggered_at: triggeredAtFallback,
},
timeRange,
clickhouseSettings: {
...getDefaultClickhouseSettings(),
...baseOptions.clickhouseSettings, // Allow caller overrides if needed
@@ -167,7 +253,7 @@ export async function executeQuery<TOut extends z.ZodSchema>(
// If query failed, return early with no queryId
if (result[0] !== null) {
return [result[0], null, null];
return { success: false, error: result[0] };
}
let queryId: string | null = null;
@@ -183,10 +269,23 @@ export async function executeQuery<TOut extends z.ZodSchema>(
userId: history.userId ?? null,
},
orderBy: { createdAt: "desc" },
select: { id: true, query: true, scope: true, filterPeriod: true, filterFrom: true, filterTo: true },
select: {
id: true,
query: true,
scope: true,
filterPeriod: true,
filterFrom: true,
filterTo: true,
},
});
const timeFilter = history.timeFilter;
// Save the effective period used for the query (timeFilters() handles defaults)
// Only save period if no custom from/to range was specified
const historyTimeFilter = {
period: timeFilter.from || timeFilter.to ? undefined : timeFilter.period,
from: timeFilter.from,
to: timeFilter.to,
};
const isDuplicate =
lastQuery &&
lastQuery.query === options.query &&
@@ -209,16 +308,23 @@ export async function executeQuery<TOut extends z.ZodSchema>(
projectId: scope === "project" || scope === "environment" ? projectId : null,
environmentId: scope === "environment" ? environmentId : null,
userId: history.userId ?? null,
filterPeriod: history.timeFilter?.period ?? null,
filterFrom: history.timeFilter?.from ?? null,
filterTo: history.timeFilter?.to ?? null,
filterPeriod: historyTimeFilter?.period ?? null,
filterFrom: historyTimeFilter?.from ?? null,
filterTo: historyTimeFilter?.to ?? null,
},
});
queryId = created.id;
}
}
return [null, result[1], queryId];
return {
success: true,
result: result[1],
queryId,
periodClipped: periodClipped ? maxQueryPeriod : null,
maxQueryPeriod,
timeRange,
};
} finally {
// Always release the concurrency slot
await queryConcurrencyLimiter.release({
+40
View File
@@ -1,6 +1,38 @@
@import url("non.geist");
@import url("non.geist/mono");
@import "react-grid-layout/css/styles.css";
@import "react-resizable/css/styles.css";
/* Override react-grid-layout placeholder color (default is red) */
.react-grid-item.react-grid-placeholder {
background: rgb(99 102 241) !important; /* indigo-500 */
border-radius: 0.375rem !important; /* rounded-md */
}
/* Sidebar reorder grid: subtle placeholder */
.sidebar-reorder-grid .react-grid-item.react-grid-placeholder {
background: rgb(39 42 46) !important; /* charcoal-700 */
border-radius: 0.25rem;
opacity: 1 !important;
}
/* Sidebar reorder grid: only animate transform (vertical position), not width/height */
.sidebar-reorder-grid .react-grid-item {
transition: transform 200ms ease !important;
}
/* Override resize handle icon to white */
.react-resizable-handle {
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDYiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiNmZmZmZmYwMCIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4Ij48ZyBvcGFjaXR5PSIwLjMwMiI+PHBhdGggZD0iTSA2IDYgTCAwIDYgTCAwIDQuMiBMIDQgNC4yIEwgNC4yIDQuMiBMIDQuMiAwIEwgNiAwIEwgNiA2IEwgNiA2IFoiIGZpbGw9IiNmZmZmZmYiLz48L2c+PC9zdmc+') !important;
}
.react-resizable-handle::after {
border-bottom-color: rgba(255, 255, 255, 0.4) !important;
border-right-color: rgba(255, 255, 255, 0.4) !important;
border-top-color: rgba(255, 255, 255, 0.4) !important;
border-left-color: rgba(255, 255, 255, 0.4) !important;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -121,6 +153,14 @@
--foreground: 210 20% 90%;
--border: 217 19% 27%;
/* Code block styling */
& [data-code-block-container] {
@apply rounded border-charcoal-650 my-0;
}
& [data-code-block] {
border-top: none;
}
& p {
@apply my-1;
}
+21 -1
View File
@@ -274,6 +274,26 @@ export function queryPath(
return `${v3EnvironmentPath(organization, project, environment)}/query`;
}
export function v3CustomDashboardPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
dashboard: { friendlyId: string }
) {
return `${v3EnvironmentPath(organization, project, environment)}/metrics/custom/${
dashboard.friendlyId
}`;
}
export function v3BuiltInDashboardPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
key: string
) {
return `${v3EnvironmentPath(organization, project, environment)}/metrics/${key}`;
}
export function v3TestTaskPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -482,7 +502,7 @@ export function v3ProjectSettingsPath(
export function v3LogsPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/logs`;
}
+5
View File
@@ -1,8 +1,12 @@
import { column, type TableSchema } from "@internal/tsql";
import { z } from "zod";
import { autoFormatSQL } from "~/components/code/TSQLEditor";
import { runFriendlyStatus, runStatusTitleFromStatus } from "~/components/runs/v3/TaskRunStatus";
import { logger } from "~/services/logger.server";
export const QueryScopeSchema = z.enum(["organization", "project", "environment"]);
export type QueryScope = z.infer<typeof QueryScopeSchema>;
/**
* Environment type values
*/
@@ -28,6 +32,7 @@ export const runsSchema: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
description: "Task runs - stores all task execution records",
timeConstraint: "triggered_at",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
@@ -414,6 +414,7 @@ HAVING cnt > 10
\`\`\`
### Date/Time Functions
- timeBucket() - automatically bucket by time. Uses the table's time column and picks the best interval based on the query's time range. Use in SELECT and reference as \`timeBucket\` in GROUP BY / ORDER BY.
- now() - current timestamp
- today() - current date
- toDate(datetime) - extract date
@@ -421,6 +422,20 @@ HAVING cnt > 10
- dateDiff('unit', start, end) - difference in units (second, minute, hour, day, week, month, year)
- INTERVAL n unit - time interval (e.g., INTERVAL 7 DAY)
### Time Bucketing
When the user wants to see data "over time", "by hour", "by day", or any time-series aggregation, prefer \`timeBucket()\` over manual \`toStartOfHour\`/\`toStartOfDay\` calls. \`timeBucket()\` automatically picks the right interval for the current time range.
\`\`\`sql
-- Runs over time (bucket size auto-selected)
SELECT timeBucket(), count() AS run_count
FROM runs
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000
\`\`\`
Only use explicit \`toStartOfHour\`/\`toStartOfDay\` etc. if the user specifically requests a particular bucket size (e.g., "group by hour", "bucket by day").
### Common Patterns
- Status filter: WHERE status = 'Failed' or WHERE status IN ('Failed', 'Crashed')
- Time filtering: Use the \`setTimeFilter\` tool (NOT triggered_at in WHERE clause)
@@ -432,13 +447,14 @@ HAVING cnt > 10
3. When column selection is ambiguous, use the core columns marked [CORE] in the schema
4. **TIME FILTERING**: When the user wants to filter by time (e.g., "last 7 days", "past hour", "yesterday"), ALWAYS use the \`setTimeFilter\` tool instead of adding \`triggered_at\` conditions to the query. The UI has a time filter that will apply this automatically.
5. Do NOT add \`triggered_at\` to WHERE clauses - use \`setTimeFilter\` tool instead. If the user doesn't specify a time period, do NOT add any time filter (the UI defaults to 7 days).
6. ALWAYS use the validateTSQLQuery tool to check your query before returning it
7. If validation fails, fix the issues and try again (up to 3 attempts)
8. Use column names exactly as defined in the schema (case-sensitive)
9. For enum columns like status, use the allowed values shown in the schema
10. Always include a LIMIT clause (default to 100 if not specified)
11. Use meaningful column aliases with AS for aggregations
12. Format queries with proper indentation for readability
6. **TIME BUCKETING**: When the user wants to see data over time or in time buckets, use \`timeBucket()\` in SELECT and reference it as \`timeBucket\` in GROUP BY / ORDER BY. Only use manual bucketing functions (toStartOfHour, toStartOfDay, etc.) when the user explicitly requests a specific bucket size.
7. ALWAYS use the validateTSQLQuery tool to check your query before returning it
8. If validation fails, fix the issues and try again (up to 3 attempts)
9. Use column names exactly as defined in the schema (case-sensitive)
10. For enum columns like status, use the allowed values shown in the schema
11. Always include a LIMIT clause (default to 100 if not specified)
12. Use meaningful column aliases with AS for aggregations
13. Format queries with proper indentation for readability
## Response Format
@@ -504,6 +520,7 @@ HAVING cnt > 10
\`\`\`
### Date/Time Functions
- timeBucket() - automatically bucket by time. Uses the table's time column and picks the best interval based on the query's time range. Use in SELECT and reference as \`timeBucket\` in GROUP BY / ORDER BY.
- now() - current timestamp
- today() - current date
- toDate(datetime) - extract date
@@ -511,18 +528,30 @@ HAVING cnt > 10
- dateDiff('unit', start, end) - difference in units (second, minute, hour, day, week, month, year)
- INTERVAL n unit - time interval (e.g., INTERVAL 7 DAY)
### Time Bucketing
When the user wants to see data "over time", "by hour", "by day", or any time-series aggregation, prefer \`timeBucket()\` over manual \`toStartOfHour\`/\`toStartOfDay\` calls unless the user specifically requests a particular bucket size.
\`\`\`sql
SELECT timeBucket(), count() AS run_count
FROM runs
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000
\`\`\`
## Important Rules
1. NEVER use SELECT * - ClickHouse is a columnar database where SELECT * has very poor performance
2. If the existing query uses SELECT *, replace it with specific columns (use core columns marked [CORE] as defaults)
3. **TIME FILTERING**: When the user wants to change time filtering (e.g., "change to last 30 days"), use the \`setTimeFilter\` tool instead of modifying \`triggered_at\` conditions. If the existing query has \`triggered_at\` in WHERE, consider removing it and using \`setTimeFilter\` instead.
4. ALWAYS use the validateTSQLQuery tool to check your modified query before returning it
5. If validation fails, fix the issues and try again (up to 3 attempts)
6. Use column names exactly as defined in the schema (case-sensitive)
7. For enum columns like status, use the allowed values shown in the schema
8. Always include a LIMIT clause (default to 100 if not specified)
9. Preserve the user's existing query structure and style where possible
10. Only make the changes specifically requested by the user
4. **TIME BUCKETING**: When adding time-series grouping, use \`timeBucket()\` in SELECT and reference it as \`timeBucket\` in GROUP BY / ORDER BY. Only use manual bucketing functions (toStartOfHour, toStartOfDay, etc.) when the user explicitly requests a specific bucket size.
5. ALWAYS use the validateTSQLQuery tool to check your modified query before returning it
6. If validation fails, fix the issues and try again (up to 3 attempts)
7. Use column names exactly as defined in the schema (case-sensitive)
8. For enum columns like status, use the allowed values shown in the schema
9. Always include a LIMIT clause (default to 100 if not specified)
10. Preserve the user's existing query structure and style where possible
11. Only make the changes specifically requested by the user
## Response Format
+3 -1
View File
@@ -121,7 +121,7 @@
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.22",
"@trigger.dev/platform": "1.0.23",
"@trigger.dev/redis-worker": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"@types/pg": "8.6.6",
@@ -186,8 +186,10 @@
"react-collapse": "^5.1.1",
"react-day-picker": "^9.13.0",
"react-dom": "^18.2.0",
"react-grid-layout": "^2.2.2",
"react-hotkeys-hook": "^4.4.1",
"react-popper": "^2.3.0",
"react-resizable": "^3.1.3",
"react-resizable-panels": "^2.0.9",
"react-stately": "^3.29.1",
"react-use": "17.5.1",
+6
View File
@@ -159,6 +159,9 @@ const runs = colors.indigo[500];
const batches = colors.pink[500];
const schedules = colors.yellow[500];
const queues = colors.purple[500];
const query = colors.blue[500];
const metrics = colors.green[500];
const customDashboards = charcoal[400];
const deployments = colors.green[500];
const concurrency = colors.amber[500];
const limits = colors.purple[500];
@@ -240,6 +243,7 @@ module.exports = {
schedules,
concurrency,
queues,
query,
regions,
limits,
deployments,
@@ -252,6 +256,8 @@ module.exports = {
orgSettings,
docs,
bulkActions,
metrics,
customDashboards,
},
focusStyles: {
outline: "1px solid",
@@ -14,6 +14,7 @@ import {
type TableSchema,
type QuerySettings,
type FieldMappings,
type TimeRange,
type WhereClauseCondition
} from "@internal/tsql";
import type { ClickhouseReader, QueryStats } from "./types.js";
@@ -25,7 +26,7 @@ const logger = new Logger("tsql", "info");
export type { QueryStats };
export type { TableSchema, QuerySettings, FieldMappings, WhereClauseCondition };
export type { TableSchema, QuerySettings, FieldMappings, TimeRange, WhereClauseCondition };
/**
* Options for executing a TSQL query
@@ -101,6 +102,12 @@ export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
* ```
*/
whereClauseFallback?: Record<string, WhereClauseCondition>;
/**
* Time range for `timeBucket()` interval calculation.
* When provided, `timeBucket()` uses this to determine the appropriate bucket size
* based on the span of the time range.
*/
timeRange?: TimeRange;
}
/**
@@ -183,6 +190,7 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
settings: compiledSettings,
fieldMappings: options.fieldMappings,
whereClauseFallback: options.whereClauseFallback,
timeRange: options.timeRange,
});
generatedSql = sql;
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE
"public"."MetricsDashboard" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"projectId" TEXT,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"layout" TEXT NOT NULL,
CONSTRAINT "MetricsDashboard_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MetricsDashboard_projectId_createdAt_idx" ON "public"."MetricsDashboard" ("projectId", "createdAt" DESC);
-- AddForeignKey
ALTER TABLE "public"."MetricsDashboard" ADD CONSTRAINT "MetricsDashboard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MetricsDashboard" ADD CONSTRAINT "MetricsDashboard_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."MetricsDashboard" ADD CONSTRAINT "MetricsDashboard_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "public"."User" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."MetricsDashboard"
ADD COLUMN "description" TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "public"."MetricsDashboard" ADD COLUMN "friendlyId" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "MetricsDashboard_friendlyId_key" ON "public"."MetricsDashboard"("friendlyId");
@@ -0,0 +1,2 @@
-- AlterTable: make ownerId nullable to match ON DELETE SET NULL foreign key
ALTER TABLE "public"."MetricsDashboard" ALTER COLUMN "ownerId" DROP NOT NULL;
+90 -57
View File
@@ -63,6 +63,7 @@ model User {
impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationAdmin")
impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget")
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
}
model MfaBackupCode {
@@ -223,6 +224,7 @@ model Organization {
workerInstances WorkerInstance[]
githubAppInstallations GithubAppInstallation[]
customerQueries CustomerQuery[]
metricsDashboards MetricsDashboard[]
}
model OrgMember {
@@ -384,32 +386,33 @@ model Project {
/// The master queues they are allowed to use (impacts what they can set as default and trigger runs with)
allowedWorkerQueues String[] @default([]) @map("allowedMasterQueues")
environments RuntimeEnvironment[]
backgroundWorkers BackgroundWorker[]
backgroundWorkerTasks BackgroundWorkerTask[]
taskRuns TaskRun[]
runTags TaskRunTag[]
taskQueues TaskQueue[]
environmentVariables EnvironmentVariable[]
checkpoints Checkpoint[]
WorkerDeployment WorkerDeployment[]
CheckpointRestoreEvent CheckpointRestoreEvent[]
taskSchedules TaskSchedule[]
alertChannels ProjectAlertChannel[]
alerts ProjectAlert[]
alertStorages ProjectAlertStorage[]
bulkActionGroups BulkActionGroup[]
BackgroundWorkerFile BackgroundWorkerFile[]
waitpoints Waitpoint[]
taskRunWaitpoints TaskRunWaitpoint[]
taskRunCheckpoints TaskRunCheckpoint[]
waitpointTags WaitpointTag[]
connectedGithubRepository ConnectedGithubRepository?
organizationProjectIntegration OrganizationProjectIntegration[]
customerQueries CustomerQuery[]
environments RuntimeEnvironment[]
backgroundWorkers BackgroundWorker[]
backgroundWorkerTasks BackgroundWorkerTask[]
taskRuns TaskRun[]
runTags TaskRunTag[]
taskQueues TaskQueue[]
environmentVariables EnvironmentVariable[]
checkpoints Checkpoint[]
WorkerDeployment WorkerDeployment[]
CheckpointRestoreEvent CheckpointRestoreEvent[]
taskSchedules TaskSchedule[]
alertChannels ProjectAlertChannel[]
alerts ProjectAlert[]
alertStorages ProjectAlertStorage[]
bulkActionGroups BulkActionGroup[]
BackgroundWorkerFile BackgroundWorkerFile[]
waitpoints Waitpoint[]
taskRunWaitpoints TaskRunWaitpoint[]
taskRunCheckpoints TaskRunCheckpoint[]
waitpointTags WaitpointTag[]
connectedGithubRepository ConnectedGithubRepository?
organizationProjectIntegration OrganizationProjectIntegration[]
customerQueries CustomerQuery[]
buildSettings Json?
taskScheduleInstances TaskScheduleInstance[]
metricsDashboards MetricsDashboard[]
}
enum ProjectVersion {
@@ -1713,7 +1716,7 @@ model EnvironmentVariableValue {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
version Int @default(1)
version Int @default(1)
lastUpdatedBy Json?
@@unique([variableId, environmentId])
@@ -1829,10 +1832,10 @@ model WorkerDeployment {
worker BackgroundWorker? @relation(fields: [workerId], references: [id], onDelete: Cascade, onUpdate: Cascade)
workerId String? @unique
triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade)
triggeredById String?
triggeredVia String?
commitSHA String?
triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade)
triggeredById String?
triggeredVia String?
commitSHA String?
startedAt DateTime?
installedAt DateTime?
@@ -1851,10 +1854,10 @@ model WorkerDeployment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
promotions WorkerDeploymentPromotion[]
alerts ProjectAlert[]
workerInstance WorkerInstance[]
integrationDeployments IntegrationDeployment[]
promotions WorkerDeploymentPromotion[]
alerts ProjectAlert[]
workerInstance WorkerInstance[]
integrationDeployments IntegrationDeployment[]
@@unique([projectId, shortCode])
@@unique([environmentId, version])
@@ -2095,8 +2098,8 @@ model OrganizationIntegration {
friendlyId String @unique
service IntegrationService
externalOrganizationId String? /// Identifier for external, integration's organization (e.g. Vercel's team)
service IntegrationService
externalOrganizationId String? /// Identifier for external, integration's organization (e.g. Vercel's team)
integrationData Json
@@ -2106,33 +2109,33 @@ model OrganizationIntegration {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
alertChannels ProjectAlertChannel[]
organizationProjectIntegration OrganizationProjectIntegration[]
alertChannels ProjectAlertChannel[]
organizationProjectIntegration OrganizationProjectIntegration[]
@@index([externalOrganizationId])
}
model OrganizationProjectIntegration {
id String @id @default(cuid())
organizationIntegration OrganizationIntegration @relation(fields: [organizationIntegrationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationIntegrationId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
externalEntityId String /// Identifier for webhooks, for example Vercel's projectId
integrationData Json /// Save useful data like config or external entity name
installedBy String? /// UserId who installed the integration
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationIntegration OrganizationIntegration @relation(fields: [organizationIntegrationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationIntegrationId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
externalEntityId String /// Identifier for webhooks, for example Vercel's projectId
integrationData Json /// Save useful data like config or external entity name
installedBy String? /// UserId who installed the integration
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([projectId])
@@index([projectId, organizationIntegrationId])
@@index([externalEntityId])
@@ -2523,19 +2526,49 @@ model CustomerQuery {
}
model IntegrationDeployment {
id String @id @default(cuid())
id String @id @default(cuid())
integrationName String /// For example Vercel
integrationDeploymentId String /// External ID
commitSHA String
deploymentId String?
status String? /// External deployment status
workerDeployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
workerDeployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([commitSHA])
@@index([deploymentId])
}
/// A user-defined metrics dashboard
model MetricsDashboard {
id String @id @default(cuid())
friendlyId String @unique
title String
description String @default("")
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationId String
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String?
/// Who created the dashboard
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull, onUpdate: Cascade)
ownerId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// JSON that defines the config, queries, layout and config of all widgets.
/// There will be a version field for the format.
layout String
/// Fast lookup for the list
@@index([projectId, createdAt(sort: Desc)])
}
+40 -2
View File
@@ -18,6 +18,7 @@ import type {
Or,
SelectQuery,
SelectSetQuery,
Tuple,
} from "./query/ast.js";
import { CompareOperationOp } from "./query/ast.js";
import { SyntaxError as TSQLSyntaxError } from "./query/errors.js";
@@ -28,6 +29,7 @@ import {
type BetweenCondition,
type QuerySettings,
type SimpleComparisonCondition,
type TimeRange,
type WhereClauseCondition,
} from "./query/printer_context.js";
import { createSchemaRegistry, type FieldMappings, type TableSchema } from "./query/schema.js";
@@ -120,13 +122,21 @@ export {
DEFAULT_QUERY_SETTINGS,
PrinterContext,
type BetweenCondition,
type InCondition,
type PrinterContextOptions,
type QueryNotice,
type QuerySettings,
type SimpleComparisonCondition,
type TimeRange,
type WhereClauseCondition,
} from "./query/printer_context.js";
// Re-export time bucket utilities
export {
calculateTimeBucketInterval,
type TimeBucketInterval,
} from "./query/time_buckets.js";
// Re-export printer
export { ClickHousePrinter, printToClickHouse, type PrintResult } from "./query/printer.js";
@@ -356,6 +366,21 @@ export function createFallbackExpression(
return betweenExpr;
}
if (fallback.op === "in") {
// Create a tuple of values for the IN clause
const tupleExpr: Tuple = {
expression_type: "tuple",
exprs: fallback.values.map((value) => createValueExpression(value)),
};
const inExpr: CompareOperation = {
expression_type: "compare_operation",
left: fieldExpr,
right: tupleExpr,
op: CompareOperationOp.In,
};
return inExpr;
}
// Simple comparison
const compareExpr: CompareOperation = {
expression_type: "compare_operation",
@@ -443,7 +468,6 @@ export function injectFallbackConditions(
};
}
/**
* Options for compiling a TSQL query to ClickHouse SQL
*/
@@ -501,6 +525,20 @@ export interface CompileTSQLOptions {
* ```
*/
whereClauseFallback?: Record<string, WhereClauseCondition>;
/**
* Time range for `timeBucket()` interval calculation.
* When provided, `timeBucket()` uses this to determine the appropriate bucket size
* based on the span of the time range.
*
* @example
* ```typescript
* {
* from: new Date('2024-01-01'),
* to: new Date('2024-01-08'),
* }
* ```
*/
timeRange?: TimeRange;
}
/**
@@ -547,7 +585,6 @@ export function compileTSQL(query: string, options: CompileTSQLOptions): PrintRe
// 3. Create schema registry from table schemas
const schemaRegistry = createSchemaRegistry(options.tableSchema);
// 4. Strip undefined values from enforcedWhereClause
const enforcedWhereClause = Object.fromEntries(
Object.entries(options.enforcedWhereClause).filter(([_, value]) => value !== undefined)
@@ -559,6 +596,7 @@ export function compileTSQL(query: string, options: CompileTSQLOptions): PrintRe
settings: options.settings,
fieldMappings: options.fieldMappings,
enforcedWhereClause,
timeRange: options.timeRange,
});
// 6. Print the AST to ClickHouse SQL (enforced conditions applied at printer level)
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { parseTSQLSelect, parseTSQLExpr } from "../index.js";
import { parseTSQLSelect, parseTSQLExpr, compileTSQL } from "../index.js";
import { ClickHousePrinter, printToClickHouse, type PrintResult } from "./printer.js";
import { createPrinterContext, PrinterContext } from "./printer_context.js";
import { createSchemaRegistry, column, type TableSchema, type SchemaRegistry } from "./schema.js";
@@ -3288,3 +3288,286 @@ describe("Required Filters", () => {
expect(sql).toContain("cost_in_cents"); // total_cost is a virtual column
});
});
// ============================================================
// timeBucket() Tests
// ============================================================
describe("timeBucket()", () => {
/**
* Schema with timeConstraint for timeBucket() tests.
* Uses column mapping: TSQL "triggered_at" ClickHouse "created_at"
*/
const timeBucketSchema: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
timeConstraint: "triggered_at",
columns: {
id: { name: "id", ...column("String") },
status: { name: "status", ...column("String") },
triggered_at: {
name: "triggered_at",
clickhouseName: "created_at",
...column("DateTime64"),
},
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
/**
* Schema without timeConstraint (for error tests)
*/
const noTimeConstraintSchema: TableSchema = {
name: "events",
clickhouseName: "trigger_dev.events_v1",
columns: {
id: { name: "id", ...column("String") },
event_type: { name: "event_type", ...column("String") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
/** 7-day time range: should produce 6 HOUR buckets */
const sevenDayRange = {
from: new Date("2024-01-01T00:00:00Z"),
to: new Date("2024-01-08T00:00:00Z"),
};
/** 1-hour time range: should produce 1 MINUTE buckets */
const oneHourRange = {
from: new Date("2024-01-01T00:00:00Z"),
to: new Date("2024-01-01T01:00:00Z"),
};
function createTimeBucketContext(
overrides: Partial<Parameters<typeof createPrinterContext>[0]> = {}
): PrinterContext {
const schema = createSchemaRegistry([timeBucketSchema]);
return createPrinterContext({
schema,
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_test123" },
project_id: { op: "eq", value: "proj_test456" },
environment_id: { op: "eq", value: "env_test789" },
},
timeRange: sevenDayRange,
...overrides,
});
}
function printTimeBucketQuery(query: string, context?: PrinterContext) {
const ast = parseTSQLSelect(query);
const ctx = context ?? createTimeBucketContext();
return printToClickHouse(ast, ctx);
}
describe("SELECT with timeBucket()", () => {
it("should compile timeBucket() to toStartOfInterval with correct column and interval", () => {
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM runs GROUP BY timeBucket"
);
// Should use ClickHouse column name (created_at), not TSQL name (triggered_at)
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 6 HOUR)");
expect(sql).toContain("count()");
});
it("should use 1 MINUTE interval for 1-hour time range", () => {
const ctx = createTimeBucketContext({ timeRange: oneHourRange });
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM runs GROUP BY timeBucket",
ctx
);
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 1 MINUTE)");
});
it("should work with other selected columns", () => {
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), status, count() FROM runs GROUP BY timeBucket, status"
);
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 6 HOUR)");
expect(sql).toContain("status");
expect(sql).toContain("count()");
});
});
describe("GROUP BY with timeBucket alias", () => {
it("should allow GROUP BY timeBucket (bare identifier, matching implicit alias)", () => {
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM runs GROUP BY timeBucket"
);
// The GROUP BY should reference the alias, not re-expand
expect(sql).toContain("GROUP BY");
// The SELECT should have the toStartOfInterval call
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 6 HOUR)");
});
it("should allow GROUP BY timebucket (all lowercase)", () => {
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM runs GROUP BY timebucket"
);
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 6 HOUR)");
});
it("should allow GROUP BY TIMEBUCKET (all uppercase)", () => {
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM runs GROUP BY TIMEBUCKET"
);
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 6 HOUR)");
});
});
describe("ORDER BY with timeBucket alias", () => {
it("should allow ORDER BY timeBucket", () => {
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM runs GROUP BY timeBucket ORDER BY timeBucket"
);
expect(sql).toContain("ORDER BY timebucket");
});
it("should allow ORDER BY timeBucket DESC", () => {
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM runs GROUP BY timeBucket ORDER BY timeBucket DESC"
);
expect(sql).toContain("ORDER BY timebucket DESC");
});
});
describe("error handling", () => {
it("should throw when timeBucket() is called with arguments", () => {
expect(() =>
printTimeBucketQuery("SELECT timeBucket(triggered_at) FROM runs")
).toThrow("timeBucket() does not accept arguments");
});
it("should throw when table has no timeConstraint", () => {
const schema = createSchemaRegistry([noTimeConstraintSchema]);
const ctx = createPrinterContext({
schema,
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_test123" },
project_id: { op: "eq", value: "proj_test456" },
environment_id: { op: "eq", value: "env_test789" },
},
timeRange: sevenDayRange,
});
expect(() =>
printTimeBucketQuery("SELECT timeBucket(), count() FROM events GROUP BY timeBucket", ctx)
).toThrow("timeConstraint");
});
it("should throw when no timeRange is provided", () => {
const ctx = createTimeBucketContext({ timeRange: undefined });
expect(() =>
printTimeBucketQuery("SELECT timeBucket(), count() FROM runs GROUP BY timeBucket", ctx)
).toThrow("time range");
});
});
describe("column name mapping", () => {
it("should resolve timeConstraint through column mapping (TSQL → ClickHouse)", () => {
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM runs GROUP BY timeBucket"
);
// timeConstraint is "triggered_at" which maps to CH "created_at"
expect(sql).toContain("created_at");
expect(sql).not.toContain("triggered_at");
});
it("should work with timeConstraint column that has no clickhouseName mapping", () => {
const schemaNoMapping: TableSchema = {
name: "logs",
clickhouseName: "trigger_dev.logs_v1",
timeConstraint: "timestamp",
columns: {
id: { name: "id", ...column("String") },
timestamp: { name: "timestamp", ...column("DateTime64") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
const schema = createSchemaRegistry([schemaNoMapping]);
const ctx = createPrinterContext({
schema,
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_test123" },
project_id: { op: "eq", value: "proj_test456" },
environment_id: { op: "eq", value: "env_test789" },
},
timeRange: sevenDayRange,
});
const { sql } = printTimeBucketQuery(
"SELECT timeBucket(), count() FROM logs GROUP BY timeBucket",
ctx
);
// No clickhouseName, so uses the TSQL name "timestamp" directly
expect(sql).toContain("toStartOfInterval(timestamp, INTERVAL 6 HOUR)");
});
});
describe("case insensitivity", () => {
it("should handle timeBucket() case-insensitively in SELECT", () => {
// The parser preserves case, but visitCall checks case-insensitively
const { sql } = printTimeBucketQuery(
"SELECT TIMEBUCKET(), count() FROM runs GROUP BY timeBucket"
);
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 6 HOUR)");
});
});
describe("integration with compileTSQL", () => {
it("should work through the full compileTSQL pipeline", () => {
const { sql, params } = compileTSQL(
"SELECT timeBucket(), count() FROM runs GROUP BY timeBucket",
{
tableSchema: [timeBucketSchema],
enforcedWhereClause: {
organization_id: { op: "eq", value: "org_test123" },
project_id: { op: "eq", value: "proj_test456" },
environment_id: { op: "eq", value: "env_test789" },
},
timeRange: sevenDayRange,
}
);
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 6 HOUR)");
expect(sql).toContain("count()");
// Tenant isolation should still be applied
expect(Object.values(params)).toContain("org_test123");
});
});
});
+108 -9
View File
@@ -47,6 +47,7 @@ import {
validateFunctionArgs,
} from "./functions";
import { PrinterContext, WhereClauseCondition } from "./printer_context";
import { calculateTimeBucketInterval } from "./time_buckets";
import {
findTable,
validateTable,
@@ -119,10 +120,12 @@ export class ClickHousePrinter {
/** Columns hidden when SELECT * is expanded to core columns only */
private hiddenColumns: string[] = [];
/**
* Set of column aliases defined in the current SELECT clause.
* Map of column aliases defined in the current SELECT clause.
* Key is the lowercase alias (for case-insensitive lookup),
* value is the canonical form (as it appears in the generated SQL).
* Used to allow ORDER BY/HAVING to reference aliased columns.
*/
private selectAliases: Set<string> = new Set();
private selectAliases: Map<string, string> = new Map();
/**
* Set of internal ClickHouse column names that are allowed (e.g., tenant columns).
* These are populated from tableSchema.tenantColumns when processing joins.
@@ -387,7 +390,7 @@ export class ClickHousePrinter {
// Extract SELECT column aliases BEFORE visiting columns
// This allows ORDER BY/HAVING to reference aliased columns
const savedAliases = this.selectAliases;
this.selectAliases = new Set();
this.selectAliases = new Map();
if (node.select) {
for (const col of node.select) {
this.extractSelectAlias(col);
@@ -569,20 +572,25 @@ export class ClickHousePrinter {
*/
private extractSelectAlias(expr: Expression): void {
// Handle explicit Alias: SELECT ... AS name
// Key is lowercase for case-insensitive lookup, value is the original casing
// so that ORDER BY/GROUP BY output matches the alias in the generated SQL.
if ((expr as Alias).expression_type === "alias") {
this.selectAliases.add((expr as Alias).alias);
const alias = (expr as Alias).alias;
this.selectAliases.set(alias.toLowerCase(), alias);
return;
}
// Handle implicit names from function calls (e.g., COUNT() → 'count')
// ClickHouse generates implicit aliases as lowercase
if ((expr as Call).expression_type === "call") {
const call = expr as Call;
// Aggregations and functions get implicit lowercase names
this.selectAliases.add(call.name.toLowerCase());
const canonicalName = call.name.toLowerCase();
this.selectAliases.set(canonicalName, canonicalName);
return;
}
// Handle implicit names from arithmetic operations (e.g., a + b → 'plus')
// ClickHouse generates these as lowercase
if ((expr as ArithmeticOperation).expression_type === "arithmetic_operation") {
const op = expr as ArithmeticOperation;
const opNames: Record<ArithmeticOperationOp, string> = {
@@ -592,7 +600,8 @@ export class ClickHousePrinter {
[ArithmeticOperationOp.Div]: "divide",
[ArithmeticOperationOp.Mod]: "modulo",
};
this.selectAliases.add(opNames[op.op]);
const canonicalName = opNames[op.op];
this.selectAliases.set(canonicalName, canonicalName);
return;
}
@@ -1691,6 +1700,21 @@ export class ClickHousePrinter {
return betweenExpr;
}
if (condition.op === "in") {
// Create a tuple of values for the IN clause
const tupleExpr: Tuple = {
expression_type: "tuple",
exprs: condition.values.map((value) => this.createValueExpression(value)),
};
const inExpr: CompareOperation = {
expression_type: "compare_operation",
left: fieldExpr,
right: tupleExpr,
op: CompareOperationOp.In,
};
return inExpr;
}
// Simple comparison
const compareExpr: CompareOperation = {
expression_type: "compare_operation",
@@ -2613,8 +2637,11 @@ export class ClickHousePrinter {
}
// Check if it's a SELECT alias (e.g., from COUNT() or explicit AS)
if (this.selectAliases.has(columnName)) {
return chain; // Valid alias reference
// Case-insensitive lookup: map key is lowercase, value is the canonical form
// that matches the alias as it appears in the generated SQL
const canonicalAlias = this.selectAliases.get(columnName.toLowerCase());
if (canonicalAlias !== undefined) {
return [canonicalAlias, ...chain.slice(1)];
}
// Check if this is an internal-only column being accessed in a user projection context
@@ -2770,6 +2797,11 @@ export class ClickHousePrinter {
private visitCall(node: Call): string {
const name = node.name;
// Handle timeBucket() - special TSQL function for automatic time bucketing
if (name.toLowerCase() === "timebucket") {
return this.visitTimeBucket(node);
}
// Check if this is a comparison function
if (name in TSQL_COMPARISON_MAPPING) {
const op = TSQL_COMPARISON_MAPPING[name];
@@ -2901,6 +2933,73 @@ export class ClickHousePrinter {
return `SAMPLE ${sample}`;
}
// ============================================================
// timeBucket() Support
// ============================================================
/**
* Handle the `timeBucket()` TSQL function.
*
* Resolves the table's timeConstraint column to its ClickHouse name,
* calculates an appropriate interval from the query's time range,
* and emits `toStartOfInterval(column, INTERVAL N UNIT)`.
*
* @throws QueryError if timeBucket() is called with arguments, or if the table
* has no timeConstraint, or if no timeRange is provided in the context.
*/
private visitTimeBucket(node: Call): string {
// Validate: timeBucket() takes no arguments
if (node.args.length > 0) {
throw new QueryError(
"timeBucket() does not accept arguments. It automatically uses the table's time constraint column."
);
}
// Find the table with a timeConstraint
const tableWithConstraint = this.findTimeConstraintTable();
if (!tableWithConstraint) {
throw new QueryError(
"timeBucket() requires a table with a timeConstraint defined in its schema."
);
}
const { tableSchema, clickhouseColumnName } = tableWithConstraint;
// Get the time range from context
const timeRange = this.context.timeRange;
if (!timeRange) {
throw new QueryError(
"timeBucket() requires a time range to be provided. Pass a timeRange option when compiling the query."
);
}
// Calculate the appropriate interval
const interval = calculateTimeBucketInterval(timeRange.from, timeRange.to);
// Emit toStartOfInterval(column, INTERVAL N UNIT)
return `toStartOfInterval(${escapeClickHouseIdentifier(clickhouseColumnName)}, INTERVAL ${interval.value} ${interval.unit})`;
}
/**
* Find a table in the current query context that has a timeConstraint defined.
* Returns the table schema and the resolved ClickHouse column name for the constraint.
*/
private findTimeConstraintTable(): {
tableSchema: TableSchema;
clickhouseColumnName: string;
} | null {
for (const tableSchema of this.tableContexts.values()) {
if (tableSchema.timeConstraint) {
const columnSchema = tableSchema.columns[tableSchema.timeConstraint];
if (columnSchema) {
const clickhouseColumnName = columnSchema.clickhouseName || columnSchema.name;
return { tableSchema, clickhouseColumnName };
}
}
}
return null;
}
// ============================================================
// Helper Methods
// ============================================================
@@ -41,10 +41,30 @@ export interface BetweenCondition {
}
/**
* A WHERE clause condition that can be either a simple comparison or a BETWEEN.
* An IN condition (e.g., column IN ('a', 'b', 'c'))
*/
export interface InCondition {
/** The in operator */
op: "in";
/** The values to check against */
values: (string | number)[];
}
/**
* A WHERE clause condition that can be either a simple comparison, a BETWEEN, or an IN.
* Used for both enforcedWhereClause (always applied) and whereClauseFallback (default when user doesn't filter).
*/
export type WhereClauseCondition = SimpleComparisonCondition | BetweenCondition;
export type WhereClauseCondition = SimpleComparisonCondition | BetweenCondition | InCondition;
/**
* A time range used by `timeBucket()` to determine the appropriate bucket interval.
*/
export interface TimeRange {
/** Start of the time range (inclusive) */
from: Date;
/** End of the time range (inclusive) */
to: Date;
}
/**
* Default query settings
@@ -99,6 +119,12 @@ export class PrinterContext {
*/
readonly enforcedWhereClause: Record<string, WhereClauseCondition>;
/**
* Time range for `timeBucket()` interval calculation.
* When provided, `timeBucket()` uses this to determine the appropriate bucket size.
*/
readonly timeRange?: TimeRange;
constructor(
/** Schema registry containing allowed tables and columns */
public readonly schema: SchemaRegistry,
@@ -110,12 +136,15 @@ export class PrinterContext {
* Enforced WHERE conditions that are ALWAYS applied at the table level.
* Must include tenant columns (e.g., organization_id) for multi-tenant tables.
*/
enforcedWhereClause: Record<string, WhereClauseCondition> = {}
enforcedWhereClause: Record<string, WhereClauseCondition> = {},
/** Time range for timeBucket() interval calculation */
timeRange?: TimeRange
) {
// Initialize with default settings
this.settings = { ...DEFAULT_QUERY_SETTINGS, ...settings };
this.fieldMappings = fieldMappings;
this.enforcedWhereClause = enforcedWhereClause;
this.timeRange = timeRange;
}
/**
@@ -195,7 +224,8 @@ export class PrinterContext {
this.schema,
this.settings,
this.fieldMappings,
this.enforcedWhereClause
this.enforcedWhereClause,
this.timeRange
);
// Share the same values map so parameters are unified
child.values = this.values;
@@ -241,6 +271,11 @@ export interface PrinterContextOptions {
* ```
*/
enforcedWhereClause: Record<string, WhereClauseCondition>;
/**
* Time range for `timeBucket()` interval calculation.
* When provided, `timeBucket()` uses this to determine the appropriate bucket size.
*/
timeRange?: TimeRange;
}
/**
@@ -251,7 +286,7 @@ export function createPrinterContext(options: PrinterContextOptions): PrinterCon
options.schema,
options.settings,
options.fieldMappings,
options.enforcedWhereClause
options.enforcedWhereClause,
options.timeRange
);
}
@@ -336,6 +336,24 @@ export interface TableSchema {
* These are injected into the WHERE clause automatically, similar to tenant isolation.
*/
requiredFilters?: RequiredFilter[];
/**
* The TSQL column name used as the time constraint for `timeBucket()`.
*
* When set, `timeBucket()` resolves to `toStartOfInterval(clickhouse_column, INTERVAL ...)`,
* using the ClickHouse column name mapped from this TSQL column.
*
* @example
* ```typescript
* {
* name: "runs",
* timeConstraint: "triggered_at", // TSQL column name; maps to CH column "created_at"
* columns: {
* triggered_at: { name: "triggered_at", clickhouseName: "created_at", ...column("DateTime64") },
* },
* }
* ```
*/
timeConstraint?: string;
}
/**
@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import { calculateTimeBucketInterval, type TimeBucketInterval } from "./time_buckets.js";
/**
* Helper to create a Date range from a start date and a duration
*/
function makeRange(from: Date, durationMs: number): { from: Date; to: Date } {
return { from, to: new Date(from.getTime() + durationMs) };
}
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe("calculateTimeBucketInterval", () => {
describe("small ranges (seconds-level buckets)", () => {
it("should return 5 SECOND for a 1-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 1 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 5,
unit: "SECOND",
});
});
it("should return 5 SECOND for a 4-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 4 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 5,
unit: "SECOND",
});
});
it("should return 30 SECOND for a 10-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 10 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 30,
unit: "SECOND",
});
});
it("should return 30 SECOND for a 29-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 29 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 30,
unit: "SECOND",
});
});
});
describe("medium ranges (minute-level buckets)", () => {
it("should return 1 MINUTE for a 45-minute range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 45 * MINUTE);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "MINUTE",
});
});
it("should return 1 MINUTE for a 1-hour range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 1 * HOUR);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "MINUTE",
});
});
it("should return 5 MINUTE for a 3-hour range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 3 * HOUR);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 5,
unit: "MINUTE",
});
});
it("should return 15 MINUTE for a 12-hour range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 12 * HOUR);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 15,
unit: "MINUTE",
});
});
});
describe("large ranges (hour/day-level buckets)", () => {
it("should return 1 HOUR for a 2-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 2 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "HOUR",
});
});
it("should return 6 HOUR for a 7-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 7 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 6,
unit: "HOUR",
});
});
it("should return 1 DAY for a 30-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 30 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "DAY",
});
});
it("should return 1 WEEK for a 90-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 90 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "WEEK",
});
});
});
describe("very large ranges (month-level buckets)", () => {
it("should return 1 MONTH for a 365-day range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 365 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "MONTH",
});
});
it("should return 1 MONTH for a 2-year range", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 730 * DAY);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "MONTH",
});
});
});
describe("edge cases", () => {
it("should handle zero-length range (from === to)", () => {
const date = new Date("2024-01-01T00:00:00Z");
const result = calculateTimeBucketInterval(date, date);
// Zero range is under 5 minutes, so 5 SECOND
expect(result).toEqual<TimeBucketInterval>({ value: 5, unit: "SECOND" });
});
it("should handle reversed dates (to < from) using absolute difference", () => {
const from = new Date("2024-01-08T00:00:00Z");
const to = new Date("2024-01-01T00:00:00Z");
// 7 days reversed → same as 7 days forward
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 6,
unit: "HOUR",
});
});
it("should handle boundary exactly at 5 minutes", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 5 * MINUTE);
// Exactly 5 minutes is NOT under 5 minutes, so should be 30 SECOND
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 30,
unit: "SECOND",
});
});
it("should handle boundary exactly at 24 hours", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 24 * HOUR);
// Exactly 24 hours is NOT under 24 hours, so should be 1 HOUR
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 1,
unit: "HOUR",
});
});
it("should handle very small range (1 second)", () => {
const { from, to } = makeRange(new Date("2024-01-01T00:00:00Z"), 1 * SECOND);
expect(calculateTimeBucketInterval(from, to)).toEqual<TimeBucketInterval>({
value: 5,
unit: "SECOND",
});
});
});
});
@@ -0,0 +1,86 @@
/**
* Time bucket interval calculation for the `timeBucket()` TSQL function.
*
* Given a time range, determines the most appropriate bucket interval
* to produce a reasonable number of data points (~50-100 buckets).
*/
/**
* A time bucket interval with a numeric value and time unit.
* Used to generate ClickHouse `INTERVAL N UNIT` syntax.
*/
export interface TimeBucketInterval {
/** The numeric value of the interval (e.g., 5 for "5 MINUTE") */
value: number;
/** The time unit */
unit: "SECOND" | "MINUTE" | "HOUR" | "DAY" | "WEEK" | "MONTH";
}
/**
* Time bucket thresholds: each entry defines a maximum time range duration (in seconds)
* and the corresponding bucket interval to use.
*
* The intervals are chosen to produce roughly 50-100 data points for the given range.
* Entries are ordered from smallest to largest range.
*/
const BUCKET_THRESHOLDS: Array<{ maxRangeSeconds: number; interval: TimeBucketInterval }> = [
// Under 5 minutes → 5 second buckets (max 60 buckets)
{ maxRangeSeconds: 5 * 60, interval: { value: 5, unit: "SECOND" } },
// Under 30 minutes → 30 second buckets (max 60 buckets)
{ maxRangeSeconds: 30 * 60, interval: { value: 30, unit: "SECOND" } },
// Under 2 hours → 1 minute buckets (max 120 buckets)
{ maxRangeSeconds: 2 * 60 * 60, interval: { value: 1, unit: "MINUTE" } },
// Under 6 hours → 5 minute buckets (max 72 buckets)
{ maxRangeSeconds: 6 * 60 * 60, interval: { value: 5, unit: "MINUTE" } },
// Under 24 hours → 15 minute buckets (max 96 buckets)
{ maxRangeSeconds: 24 * 60 * 60, interval: { value: 15, unit: "MINUTE" } },
// Under 3 days → 1 hour buckets (max 72 buckets)
{ maxRangeSeconds: 3 * 24 * 60 * 60, interval: { value: 1, unit: "HOUR" } },
// Under 14 days → 6 hour buckets (max 56 buckets)
{ maxRangeSeconds: 14 * 24 * 60 * 60, interval: { value: 6, unit: "HOUR" } },
// Under 60 days → 1 day buckets (max 60 buckets)
{ maxRangeSeconds: 60 * 24 * 60 * 60, interval: { value: 1, unit: "DAY" } },
// Under 365 days → 1 week buckets (max ~52 buckets)
{ maxRangeSeconds: 365 * 24 * 60 * 60, interval: { value: 1, unit: "WEEK" } },
];
/** Default interval for very large ranges (365+ days) */
const DEFAULT_LARGE_INTERVAL: TimeBucketInterval = { value: 1, unit: "MONTH" };
/**
* Calculate the most appropriate time bucket interval for a given time range.
*
* The interval is chosen to produce a reasonable number of data points (~50-100 buckets).
* For very small ranges (< 5 minutes), uses 5-second buckets.
* For very large ranges (> 365 days), uses 1-month buckets.
*
* @param from - Start of the time range
* @param to - End of the time range
* @returns The recommended bucket interval
*
* @example
* ```typescript
* // 1 hour range → 1 minute buckets
* calculateTimeBucketInterval(
* new Date("2024-01-01T00:00:00Z"),
* new Date("2024-01-01T01:00:00Z"),
* ); // { value: 1, unit: "MINUTE" }
*
* // 7 day range → 6 hour buckets
* calculateTimeBucketInterval(
* new Date("2024-01-01"),
* new Date("2024-01-08"),
* ); // { value: 6, unit: "HOUR" }
* ```
*/
export function calculateTimeBucketInterval(from: Date, to: Date): TimeBucketInterval {
const rangeSeconds = Math.abs(to.getTime() - from.getTime()) / 1000;
for (const threshold of BUCKET_THRESHOLDS) {
if (rangeSeconds < threshold.maxRangeSeconds) {
return threshold.interval;
}
}
return DEFAULT_LARGE_INTERVAL;
}
@@ -230,8 +230,8 @@ function validateSelectQuery(node: SelectQuery, context: ValidationContext): voi
if (node.select) {
for (const expr of node.select) {
if ((expr as Alias).expression_type === "alias") {
// Explicit alias: SELECT ... AS name
context.selectAliases.add((expr as Alias).alias);
// Explicit alias: SELECT ... AS name (stored lowercase for case-insensitive lookup)
context.selectAliases.add((expr as Alias).alias.toLowerCase());
} else {
// Check for implicit aliases from expressions without AS
const implicitName = getImplicitName(expr);
@@ -439,7 +439,8 @@ function validateField(field: Field, context: ValidationContext): void {
const columnName = firstPart;
// Check if it's a SELECT alias (e.g., from "count(*) as count")
if (context.selectAliases.has(columnName)) {
// Case-insensitive: aliases are stored lowercase
if (context.selectAliases.has(columnName.toLowerCase())) {
return;
}
+59 -5
View File
@@ -498,8 +498,8 @@ importers:
specifier: workspace:*
version: link:../../internal-packages/otlp-importer
'@trigger.dev/platform':
specifier: 1.0.22
version: 1.0.22
specifier: 1.0.23
version: 1.0.23
'@trigger.dev/redis-worker':
specifier: workspace:*
version: link:../../packages/redis-worker
@@ -692,12 +692,18 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
react-grid-layout:
specifier: ^2.2.2
version: 2.2.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-hotkeys-hook:
specifier: ^4.4.1
version: 4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-popper:
specifier: ^2.3.0
version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-resizable:
specifier: ^3.1.3
version: 3.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-resizable-panels:
specifier: ^2.0.9
version: 2.0.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -10488,8 +10494,8 @@ packages:
react: ^18.2.0
react-dom: 18.2.0
'@trigger.dev/platform@1.0.22':
resolution: {integrity: sha512-tvPf40wqEDcQCZsHt/9A+WoQ08z+uObSWQ+oahqCgp3dSgKOUH8NdzZ/2ISSRiCkN2jURixNiUyDJmgsZipExg==}
'@trigger.dev/platform@1.0.23':
resolution: {integrity: sha512-/fHMOKHdqRv6t70h0weUorOeVOkX+8WGWwPlzdq+uGDqkf8ZrcwBDuBSyoG9KkyvIsA8Tw64zVbWK94CbVlznw==}
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
@@ -13804,6 +13810,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@4.0.3:
resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==}
fast-equals@5.0.1:
resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==}
engines: {node: '>=6.0.0'}
@@ -17616,6 +17625,12 @@ packages:
peerDependencies:
react: ^19.1.0
react-draggable@4.5.0:
resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-email@2.1.2:
resolution: {integrity: sha512-HBHhpzEE5es9YUoo7VSj6qy1omjwndxf3/Sb44UJm/uJ2AjmqALo2yryux0CjW9QAVfitc9rxHkLvIb9H87QQw==}
engines: {node: '>=18.0.0'}
@@ -17624,6 +17639,12 @@ packages:
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-grid-layout@2.2.2:
resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-hotkeys-hook@4.4.1:
resolution: {integrity: sha512-sClBMBioFEgFGYLTWWRKvhxcCx1DRznd+wkFHwQZspnRBkHTgruKIHptlK/U/2DPX8BhHoRGzpMVWUXMmdZlmw==}
peerDependencies:
@@ -17682,6 +17703,12 @@ packages:
react: ^16.14.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0
react-resizable@3.1.3:
resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==}
peerDependencies:
react: '>= 16.3'
react-dom: '>= 16.3'
react-router-dom@6.17.0:
resolution: {integrity: sha512-qWHkkbXQX+6li0COUUPKAUkxjNNqPJuiBd27dVwQGDNsuFBdMbrS6UZ0CLYc4CsbdLYTckn4oB4tGDuPZpPhaQ==}
engines: {node: '>=14.0.0'}
@@ -30641,7 +30668,7 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@trigger.dev/platform@1.0.22':
'@trigger.dev/platform@1.0.23':
dependencies:
zod: 3.23.8
@@ -34677,6 +34704,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-equals@4.0.3: {}
fast-equals@5.0.1: {}
fast-fifo@1.3.2: {}
@@ -39128,6 +39157,13 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
react-draggable@4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
clsx: 2.1.1
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0):
dependencies:
'@babel/parser': 7.24.1
@@ -39189,6 +39225,17 @@ snapshots:
react-fast-compare@3.2.2: {}
react-grid-layout@2.2.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
clsx: 2.1.1
fast-equals: 4.0.3
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-resizable: 3.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
resize-observer-polyfill: 1.5.1
react-hotkeys-hook@4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
react: 18.2.0
@@ -39307,6 +39354,13 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-resizable@3.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-router-dom@6.17.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@remix-run/router': 1.10.0