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:
Vendored
-1
@@ -7,6 +7,5 @@
|
||||
"packages/cli-v3/e2e": true
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.experimental.useTsgo": true,
|
||||
"chat.agent.maxRequests": 10000
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: 0–360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
|
||||
* s: 0–100 (saturation — 0 is gray, 100 is full color)
|
||||
* l: 0–100 (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: 0–360, s: 0–100, l: 0–100) 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: 0–360, s: 0–100, l: 0–100) */
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
+17
-7
@@ -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>
|
||||
|
||||
+295
@@ -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 };
|
||||
}
|
||||
+772
@@ -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>
|
||||
);
|
||||
}
|
||||
+9
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -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.",
|
||||
|
||||
+31
-20
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -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()" },
|
||||
|
||||
+45
-915
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+492
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
+84
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+25
@@ -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;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."MetricsDashboard"
|
||||
ADD COLUMN "description" TEXT NOT NULL DEFAULT '';
|
||||
+5
@@ -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");
|
||||
+2
@@ -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;
|
||||
@@ -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)])
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Generated
+59
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user