Merge branch 'main' into HEAD

This commit is contained in:
nicktrn
2026-02-18 16:32:22 +00:00
160 changed files with 12125 additions and 3360 deletions
+34
View File
@@ -0,0 +1,34 @@
---
"@trigger.dev/sdk": minor
---
Added `query.execute()` which lets you query your Trigger.dev data using TRQL (Trigger Query Language) and returns results as typed JSON rows or CSV. It supports configurable scope (environment, project, or organization), time filtering via `period` or `from`/`to` ranges, and a `format` option for JSON or CSV output.
```typescript
import { query } from "@trigger.dev/sdk";
import type { QueryTable } from "@trigger.dev/sdk";
// Basic untyped query
const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10");
// Type-safe query using QueryTable to pick specific columns
const typedResult = await query.execute<QueryTable<"runs", "run_id" | "status" | "triggered_at">>(
"SELECT run_id, status, triggered_at FROM runs LIMIT 10"
);
typedResult.results.forEach(row => {
console.log(row.run_id, row.status); // Fully typed
});
// Aggregation query with inline types
const stats = await query.execute<{ status: string; count: number }>(
"SELECT status, COUNT(*) as count FROM runs GROUP BY status",
{ scope: "project", period: "30d" }
);
// CSV export
const csv = await query.execute(
"SELECT run_id, status FROM runs",
{ format: "csv", period: "7d" }
);
console.log(csv.results); // Raw CSV string
```
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Add optional `timeoutInSeconds` parameter to the `wait_for_run_to_complete` MCP tool. Defaults to 60 seconds. If the run doesn't complete within the timeout, the current state of the run is returned instead of waiting indefinitely.
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/core": patch
---
Fixed a minor issue in the deployment command on distinguishing between local builds for the cloud vs local builds for self-hosting setups.
+4 -1
View File
@@ -10,4 +10,7 @@ mpcgrid
myftija
nicktrn
samejr
isshaddad
isshaddad
# Outside contributors
gautamsi
capaj
+9
View File
@@ -31,6 +31,15 @@
"cwd": "${workspaceFolder}/apps/webapp",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug opened test file",
"command": "pnpm run test -- ./${relativeFile}",
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceFolder}",
"sourceMaps": true
},
{
"type": "chrome",
"request": "launch",
-1
View File
@@ -7,6 +7,5 @@
"packages/cli-v3/e2e": true
},
"vitest.disableWorkspaceWarning": true,
"typescript.experimental.useTsgo": true,
"chat.agent.maxRequests": 10000
}
+2
View File
@@ -6,6 +6,8 @@ Please take some time to read this guide to understand contributing best practic
Thank you for helping us make Trigger.dev even better! 🤩
> **Important:** We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one.
## Getting vouched (required before opening a PR)
We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. **PRs from unvouched users are automatically closed.**
+29
View File
@@ -30,3 +30,32 @@ export function AlphaTitle({ children }: { children: React.ReactNode }) {
</>
);
}
export function BetaBadge({
inline = false,
className,
}: {
inline?: boolean;
className?: string;
}) {
return (
<SimpleTooltip
button={
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
Beta
</Badge>
}
content="This feature is in Beta."
disableHoverableContent
/>
);
}
export function BetaTitle({ children }: { children: React.ReactNode }) {
return (
<>
<span>{children}</span>
<BetaBadge />
</>
);
}
@@ -1,7 +1,6 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { LinkButton } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { LogLevel } from "./logs/LogLevel";
export function LogLevelTooltipInfo() {
return (
@@ -13,51 +12,45 @@ export function LogLevelTooltipInfo() {
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-blue-400">Info</Header3>
<div className="mb-1">
<LogLevel level="TRACE" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Traces and spans representing the execution flow of your tasks.
</Paragraph>
</div>
<div>
<div className="mb-1">
<LogLevel level="INFO" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
General informational messages about task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-warning">Warn</Header3>
<div className="mb-1">
<LogLevel level="WARN" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Warning messages indicating potential issues that don't prevent execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-error">Error</Header3>
<div className="mb-1">
<LogLevel level="ERROR" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Error messages for failures and exceptions during task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-charcoal-400">Debug</Header3>
<div className="mb-1">
<LogLevel level="DEBUG" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Detailed diagnostic information for development and debugging.
</Paragraph>
</div>
<div className="border-t border-charcoal-700 pt-4">
<Header3>Tracing & Spans</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Automatically track the flow of your code through task triggers, attempts, and HTTP
requests. Create custom traces to monitor specific operations.
</Paragraph>
</div>
<LinkButton
to="https://trigger.dev/docs/logging#tracing-and-spans"
variant="docs/small"
LeadingIcon={BookOpenIcon}
>
Read docs
</LinkButton>
</div>
);
}
+6
View File
@@ -193,6 +193,12 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Metrics page</Header3>
<Shortcut name="Toggle fullscreen chart">
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Schedules page</Header3>
<Shortcut name="New schedule">
@@ -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,20 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { BarChart, LineChart, Plus, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { IconSortAscending, IconSortDescending } from "@tabler/icons-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 SegmentedControl from "../primitives/SegmentedControl";
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 +25,7 @@ export const defaultChartConfig: ChartConfiguration = {
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
};
interface ChartConfigPanelProps {
@@ -155,8 +149,11 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
if (needsUpdate) {
onChangeRef.current({ ...currentConfig, ...updates });
}
// Only re-run when the actual column structure changes, not on every config change
}, [columnsKey, columns, dateTimeColumns, categoricalColumns, numericColumns]);
// Only re-run when the actual column structure changes, not on every config change.
// columnsKey (a string) is stable when columns match, so this won't re-fire
// unnecessarily when the same query is re-run with identical columns.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnsKey]);
const updateConfig = useCallback(
(updates: Partial<ChartConfiguration>) => {
@@ -239,54 +236,38 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
}
return (
<div className={cn("flex flex-col gap-2 p-2", className)}>
<div className={cn("flex flex-col gap-3 p-2", className)}>
{/* Chart Type */}
<div className="flex flex-col gap-3">
<ConfigField label="Type">
<div className="flex items-center">
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-r-none border-b pl-1 pr-2",
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "bar" })}
LeadingIcon={BarChart}
leadingIconClassName={
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
Bar
</span>
</Button>
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-l-none border-b pl-1 pr-2",
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "line" })}
LeadingIcon={LineChart}
leadingIconClassName={
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
>
Line
</span>
</Button>
</div>
<SegmentedControl
name="chartType"
value={config.chartType}
variant="secondary/small"
options={[
{
label: (
<span className="flex items-center gap-1">
<BarChart className="size-3" /> Bar
</span>
),
value: "bar",
},
{
label: (
<span className="flex items-center gap-1">
<LineChart className="size-3" /> Line
</span>
),
value: "line",
},
]}
onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })}
/>
</ConfigField>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
{/* X-Axis */}
<ConfigField label="X-Axis">
<Select
@@ -329,60 +310,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 +446,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__"}
@@ -510,9 +515,29 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
{/* Sort Direction (only when sorting) */}
{config.sortByColumn && (
<ConfigField label="Sort direction">
<SortDirectionToggle
direction={config.sortDirection}
onChange={(direction) => updateConfig({ sortDirection: direction })}
<SegmentedControl
name="sortDirection"
value={config.sortDirection}
variant="secondary/small"
options={[
{
label: (
<span className="flex items-center gap-1">
<IconSortAscending className="size-3" /> Asc
</span>
),
value: "asc",
},
{
label: (
<span className="flex items-center gap-1">
<IconSortDescending className="size-3" /> Desc
</span>
),
value: "desc",
},
]}
onChange={(value) => updateConfig({ sortDirection: value as SortDirection })}
/>
</ConfigField>
)}
@@ -524,48 +549,55 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
{label && <span className="text-xs text-text-dimmed">{label}</span>}
{label && <span className="text-xs text-text-bright">{label}</span>}
{children}
</div>
);
}
function SortDirectionToggle({
direction,
onChange,
function SeriesColorPicker({
color,
onColorChange,
}: {
direction: SortDirection;
onChange: (direction: SortDirection) => void;
color: string;
onColorChange: (color: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="flex gap-1">
<button
type="button"
onClick={() => onChange("asc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "asc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Ascending"
>
Asc
</button>
<button
type="button"
onClick={() => onChange("desc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "desc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Descending"
>
Desc
</button>
</div>
<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>
);
}
@@ -68,6 +68,9 @@ type CodeBlockProps = {
/** Search term to highlight in the code */
searchTerm?: string;
/** Whether to wrap the code */
wrap?: boolean;
};
const dimAmount = 0.5;
@@ -207,6 +210,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
fileName,
rowTitle,
searchTerm,
wrap = false,
...props
}: CodeBlockProps,
ref
@@ -215,7 +219,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
const [copied, setCopied] = useState(false);
const [modalCopied, setModalCopied] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isWrapped, setIsWrapped] = useState(false);
const [isWrapped, setIsWrapped] = useState(wrap);
const onCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
@@ -1,60 +1,26 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { BarChart3, LineChart } from "lucide-react";
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 { ChartBlankState } from "../primitives/charts/ChartBlankState";
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
import { aggregateValues } from "../primitives/charts/aggregation";
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 +119,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 +171,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 +195,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)
@@ -281,17 +245,18 @@ function fillTimeGaps(
}
filledData.push(point);
} else {
// Create a zero-filled data point
const zeroPoint: Record<string, unknown> = {
// Create a null-filled data point so gaps appear in line/bar charts
// and legend aggregations (avg/min/max) skip these slots
const gapPoint: Record<string, unknown> = {
[xDataKey]: t,
__rawDate: new Date(t),
__granularity: granularity,
__originalX: new Date(t).toISOString(),
};
for (const s of series) {
zeroPoint[s] = 0;
gapPoint[s] = null;
}
filledData.push(zeroPoint);
filledData.push(gapPoint);
}
}
@@ -390,22 +355,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 +438,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 +465,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 +553,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 +636,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
);
@@ -657,25 +674,6 @@ function toNumber(value: unknown): number {
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: AggregationType): 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);
}
}
/**
* Sort data array by a specified column
*/
@@ -725,8 +723,10 @@ export const QueryResultsChart = memo(function QueryResultsChart({
rows,
columns,
config,
timeRange,
fullLegend = false,
onViewAllLegendItems,
isLoading = false,
legendScrollable = false,
}: QueryResultsChartProps) {
const {
@@ -748,7 +748,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 +759,37 @@ 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]
);
// Sort series by descending total sum so largest appears at bottom of
// stacked charts and first in the legend
const sortedSeries = useMemo(() => {
if (series.length <= 1) return series;
const totals = new Map<string, number>();
for (const s of series) {
let total = 0;
for (const point of data) {
const val = point[s];
if (typeof val === "number" && isFinite(val)) {
total += Math.abs(val);
}
}
totals.set(s, total);
}
return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0));
}, [series, data]);
// X-axis tick formatter for date-based axes
// 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 (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 +801,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) => {
sortedSeries.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]);
}, [sortedSeries, groupByIsRunStatus, config.seriesColors]);
// Custom tooltip label formatter for better date display
const tooltipLabelFormatter = useMemo(() => {
@@ -831,30 +863,121 @@ 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
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
if (!xAxisColumn) {
return <EmptyState message="Select an X-axis column to display the chart" />;
return <ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />;
}
if (yAxisColumns.length === 0) {
return <EmptyState message="Select a Y-axis column to display the chart" />;
return <ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />;
}
if (rows.length === 0) {
return <EmptyState message="No data to display" />;
return <ChartBlankState icon={chartIcon} message="No data to display" />;
}
if (data.length === 0) {
return <EmptyState message="Unable to transform data for chart" />;
return <ChartBlankState icon={chartIcon} 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 +987,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
@@ -883,7 +1006,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
domain: yAxisDomain,
};
const showLegend = series.length > 0;
const showLegend = sortedSeries.length > 0;
if (chartType === "bar") {
return (
@@ -891,14 +1014,16 @@ export const QueryResultsChart = memo(function QueryResultsChart({
config={chartConfig}
data={data}
dataKey={xDataKey}
series={series}
series={sortedSeries}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
>
<Chart.Bar
xAxisProps={xAxisPropsForBar}
@@ -916,19 +1041,21 @@ export const QueryResultsChart = memo(function QueryResultsChart({
config={chartConfig}
data={data}
dataKey={xDataKey}
series={series}
series={sortedSeries}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
>
<Chart.Line
xAxisProps={xAxisPropsForLine}
yAxisProps={yAxisProps}
stacked={stacked && series.length > 1}
stacked={stacked && sortedSeries.length > 1}
tooltipLabelFormatter={tooltipLabelFormatter}
lineType="linear"
/>
@@ -989,12 +1116,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
};
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-full min-h-[300px] items-center justify-center">
<Paragraph variant="small" className="text-text-dimmed">
{message}
</Paragraph>
</div>
);
}
+41 -2
View File
@@ -284,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) {
}}
/>
{showButtons && (
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-1.5">
{additionalActions && additionalActions}
{showFormatButton && (
<Button
@@ -338,11 +338,50 @@ export function TSQLEditor(opts: TSQLEditorProps) {
);
}
// SQL keywords that legitimately appear before parentheses with a space
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
"IN",
"NOT",
"EXISTS",
"OVER",
"USING",
"VALUES",
"BETWEEN",
"LIKE",
"AND",
"OR",
"ON",
"SET",
"INTO",
"TABLE",
"CASE",
"WHEN",
"THEN",
"ELSE",
"AS",
"FROM",
"WHERE",
"HAVING",
"JOIN",
"SELECT",
]);
export function autoFormatSQL(sql: string) {
return formatSQL(sql, {
let formatted = formatSQL(sql, {
language: "sql",
keywordCase: "upper",
indentStyle: "standard",
linesBetweenQueries: 2,
});
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
// Remove that space for anything that isn't a SQL keyword
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
return match;
}
return `${name}(`;
});
return formatted;
}
@@ -1,23 +1,25 @@
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { IconFilter2, IconFilter2X, IconTable } 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";
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { AlertCircle, ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { forwardRef, memo, useEffect, useMemo, useRef, useState } from "react";
import { EnvironmentLabel, EnvironmentSlug } from "~/components/environments/EnvironmentLabel";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
@@ -35,16 +37,12 @@ import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
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 +52,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 +158,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
@@ -416,7 +414,7 @@ function CellValueWrapper({
return (
<span
className="flex-1"
className="flex flex-1 items-center"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -462,6 +460,7 @@ function CellValue({
</pre>
}
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
disableHoverableContent
/>
);
}
@@ -482,7 +481,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 +496,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 +611,7 @@ function CellValue({
{truncateString(arrayString)}
</span>
}
disableHoverableContent
/>
);
}
@@ -642,6 +647,7 @@ function CellValue({
</pre>
}
button={<span>{truncateString(stringValue)}</span>}
disableHoverableContent
/>
);
}
@@ -688,6 +694,7 @@ function JSONCellValue({ value }: { value: unknown }) {
button={
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
}
disableHoverableContent
/>
);
}
@@ -713,15 +720,16 @@ function CopyableCell({
return (
<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",
"relative flex h-full w-full items-center overflow-hidden px-2",
"bg-background-bright group-hover/row:bg-charcoal-750",
"font-mono text-xs text-text-dimmed group-hover/row:text-text-bright",
"[&_a:focus-visible]:underline [&_a:focus-visible]:underline-offset-[3px] [&_a:focus-visible]:outline-none",
alignment === "right" && "justify-end"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className="truncate">{children}</span>
<span className="flex items-center truncate">{children}</span>
{isHovered && (
<span
onClick={(e) => {
@@ -781,18 +789,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 +813,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 +828,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 +848,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 focus-custom 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 +885,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 +905,15 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
rows,
columns,
prettyFormatting = true,
sorting: defaultSorting = [],
showHeaderOnEmpty = false,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
prettyFormatting?: boolean;
sorting?: SortingState;
/** When true, show column headers + "No results" on empty data. When false, show a blank state icon. */
showHeaderOnEmpty?: boolean;
}) {
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -899,7 +923,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
@@ -959,6 +983,10 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
// Empty state
if (rows.length === 0) {
if (!showHeaderOnEmpty) {
return <ChartBlankState icon={IconTable} message="No data to display" />;
}
return (
<div
className="h-full min-h-0 w-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
@@ -966,7 +994,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 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
style={{
display: "grid",
position: "sticky",
@@ -987,63 +1015,24 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
width: header.getSize(),
}}
>
<HeaderCellContent
alignment={meta?.alignment ?? "left"}
tooltip={meta?.outputColumn.description}
onFilterClick={() => {
if (!showFilters) {
setFocusFilterColumn(header.id);
} else {
setColumnFilters([]);
}
setShowFilters(!showFilters);
}}
showFilters={showFilters}
hasActiveFilter={!!header.column.getFilterValue()}
sortDirection={header.column.getIsSorted()}
onSortClick={header.column.getToggleSortingHandler()}
canSort={header.column.getCanSort()}
>
<HeaderCellContent alignment={meta?.alignment ?? "left"}>
{flexRender(header.column.columnDef.header, header.getContext())}
</HeaderCellContent>
{/* Column resizer */}
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"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"
)}
/>
</th>
);
})}
</tr>
))}
{/* Filter row - shown when filters are toggled */}
{showFilters && (
<tr style={{ display: "flex", width: "100%" }}>
{table.getHeaderGroups()[0]?.headers.map((header) => (
<FilterCell
key={`filter-${header.id}`}
column={header.column}
width={header.getSize()}
shouldFocus={focusFilterColumn === header.id}
onFocused={() => setFocusFilterColumn(null)}
/>
))}
</tr>
)}
</thead>
<tbody style={{ display: "grid" }}>
<tr style={{ display: "flex" }}>
<td>
<Paragraph variant="extra-small" className="p-4 text-text-dimmed">
No results
</Paragraph>
<tr style={{ display: "flex", width: "100%" }}>
<td className="w-full px-3 py-6" colSpan={columns.length}>
<div className="flex items-center justify-center gap-1.5">
<AlertCircle className="size-5 text-text-dimmed/50" />
<Paragraph variant="small" className="text-text-dimmed">
This query returned no results
</Paragraph>
</div>
</td>
</tr>
</tbody>
@@ -1060,7 +1049,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 +1096,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 +1128,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,12 +1136,13 @@ 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",
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
height: `${virtualRow.size}px`,
}}
>
{row.getVisibleCells().map((cell) => {
@@ -0,0 +1,183 @@
/**
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
*
* HSL is a human-friendly color model:
* h: 0360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
* s: 0100 (saturation — 0 is gray, 100 is full color)
* l: 0100 (lightness — 0 is black, 50 is pure color, 100 is white)
*/
interface HSLColor {
h: number;
s: number;
l: number;
}
interface ChartColorDef {
name: string;
hsl: HSLColor;
}
// ---------------------------------------------------------------------------
// Palette — 30 distinct colors for chart series, defined in HSL
// ---------------------------------------------------------------------------
const CHART_COLOR_DEFS: ChartColorDef[] = [
// Primary colors (high contrast, spread across hue wheel)
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
// Extended palette
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
// Additional distinct colors (lighter variants)
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
];
// ---------------------------------------------------------------------------
// HSL ↔ Hex conversion
// ---------------------------------------------------------------------------
/** Convert an HSL color (h: 0360, s: 0100, l: 0100) to a hex string */
function hslToHex({ h, s, l }: HSLColor): string {
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const hPrime = h / 60;
const x = c * (1 - Math.abs((hPrime % 2) - 1));
const m = lNorm - c / 2;
let r1: number, g1: number, b1: number;
if (hPrime < 1) {
r1 = c;
g1 = x;
b1 = 0;
} else if (hPrime < 2) {
r1 = x;
g1 = c;
b1 = 0;
} else if (hPrime < 3) {
r1 = 0;
g1 = c;
b1 = x;
} else if (hPrime < 4) {
r1 = 0;
g1 = x;
b1 = c;
} else if (hPrime < 5) {
r1 = x;
g1 = 0;
b1 = c;
} else {
r1 = c;
g1 = 0;
b1 = x;
}
const toHex = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, "0");
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
}
/** Convert a hex string to HSL (h: 0360, s: 0100, l: 0100) */
function hexToHsl(hex: string): HSLColor {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
const l = (max + min) / 2;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const s = delta / (1 - Math.abs(2 * l - 1));
let h: number;
if (max === r) {
h = 60 * (((g - b) / delta + 6) % 6);
} else if (max === g) {
h = 60 * ((b - r) / delta + 2);
} else {
h = 60 * ((r - g) / delta + 4);
}
return {
h: Math.round(h),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
// ---------------------------------------------------------------------------
// Derived hex palette (for consumers that need plain hex strings)
// ---------------------------------------------------------------------------
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
/** Get the hex color for a series by its index (wraps around) */
export function getSeriesColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
// ---------------------------------------------------------------------------
// Hue-sorted palette (rainbow order for color pickers)
// ---------------------------------------------------------------------------
const SATURATION_THRESHOLD = 10;
/**
* Chart colors sorted by perceived hue — the natural rainbow order
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
*
* Very desaturated colors (like grays) are placed at the end since they don't
* have a strong hue.
*/
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
.sort((a, b) => {
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
// Push desaturated colors to the end
if (aIsGray && !bIsGray) return 1;
if (!aIsGray && bIsGray) return -1;
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
// Sort by hue, then by saturation (more vivid first), then by lightness
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
return a.hsl.l - b.hsl.l;
})
.map((def) => hslToHex(def.hsl));
@@ -67,9 +67,10 @@ export function darkTheme(): Extension {
},
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: selection,
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: selection,
},
".cm-panels": { backgroundColor: darkBackground, color: ivory },
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
@@ -87,8 +88,8 @@ export function darkTheme(): Extension {
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
backgroundColor: "#bad0f847",
outline: "1px solid #515a6b",
backgroundColor: "rgba(18, 19, 23, 0.9)",
outline: "1px solid rgba(81, 90, 107, 0.5)",
},
".cm-gutters": {
@@ -166,14 +167,20 @@ export function darkTheme(): Extension {
backgroundColor: scrollbarBg,
},
},
{ dark: true }
{ dark: true },
);
/// The highlighting style for code in the JSON Hero theme.
const jsonHeroHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: violet },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
tag: [
tags.name,
tags.deleted,
tags.character,
tags.propertyName,
tags.macroName,
],
color: lilac,
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: malibu },
@@ -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;
}
@@ -47,6 +47,7 @@ import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresent
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { useEffect, useState, useCallback, useRef } from "react";
import { usePostHogTracking } from "~/hooks/usePostHog";
function safeRedirectUrl(url: string): string | null {
try {
@@ -114,6 +115,7 @@ export function VercelOnboardingModal({
nextUrl?: string;
onDataReload?: (vercelStagingEnvironment?: string) => void;
}) {
const { capture, startSessionRecording } = usePostHogTracking();
const navigation = useNavigation();
const fetcher = useTypedFetcher<typeof loader>();
const envMappingFetcher = useFetcher();
@@ -172,6 +174,33 @@ export function VercelOnboardingModal({
prevIsOpenRef.current = isOpen;
}, [isOpen, state, computeInitialState]);
const trackOnboarding = useCallback(
(eventName: string, extraProperties?: Record<string, unknown>) => {
capture(eventName, {
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
step: state,
organization_slug: organizationSlug,
project_slug: projectSlug,
...extraProperties,
});
},
[capture, fromMarketplaceContext, state, organizationSlug, projectSlug]
);
const hasTrackedStartRef = useRef(false);
const hasTrackedCompletionRef = useRef(false);
useEffect(() => {
if (isOpen && state === "project-selection" && !hasTrackedStartRef.current) {
hasTrackedStartRef.current = true;
startSessionRecording();
trackOnboarding("vercel onboarding started");
}
if (!isOpen) {
hasTrackedStartRef.current = false;
hasTrackedCompletionRef.current = false;
}
}, [isOpen, state, trackOnboarding, startSessionRecording]);
const [selectedVercelProject, setSelectedVercelProject] = useState<{
id: string;
name: string;
@@ -337,6 +366,9 @@ export function VercelOnboardingModal({
useEffect(() => {
if (state === "project-selection" && fetcher.data && "success" in fetcher.data && fetcher.data.success && fetcher.state === "idle") {
trackOnboarding("vercel onboarding project selected", {
vercel_project_name: selectedVercelProject?.name,
});
setState("loading-env-mapping");
if (onDataReload) {
onDataReload();
@@ -344,7 +376,7 @@ export function VercelOnboardingModal({
} else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") {
setProjectSelectionError(fetcher.data.error);
}
}, [state, fetcher.data, fetcher.state, onDataReload]);
}, [state, fetcher.data, fetcher.state, onDataReload, trackOnboarding, selectedVercelProject?.name]);
// For marketplace origin, skip env-mapping step
useEffect(() => {
@@ -449,14 +481,23 @@ export function VercelOnboardingModal({
method: "post",
action: actionUrl,
});
}, [actionUrl, fetcher, onClose, nextUrl, fromMarketplaceContext]);
}, [actionUrl, fetcher, onClose, fromMarketplaceContext]);
const handleSkipEnvMapping = useCallback(() => {
trackOnboarding("vercel onboarding env mapping completed", {
skipped: true,
staging_environment: null,
});
setVercelStagingEnvironment(null);
setState("loading-env-vars");
}, []);
}, [trackOnboarding]);
const handleUpdateEnvMapping = useCallback(() => {
trackOnboarding("vercel onboarding env mapping completed", {
skipped: false,
staging_environment: vercelStagingEnvironment?.displayName ?? null,
});
if (!vercelStagingEnvironment) {
setState("loading-env-vars");
return;
@@ -471,9 +512,11 @@ export function VercelOnboardingModal({
action: actionUrl,
});
}, [vercelStagingEnvironment, envMappingFetcher, actionUrl]);
}, [vercelStagingEnvironment, envMappingFetcher, actionUrl, trackOnboarding]);
const handleBuildSettingsNext = useCallback(() => {
trackOnboarding("vercel onboarding build settings completed");
if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) {
setIsRedirecting(true);
}
@@ -501,7 +544,7 @@ export function VercelOnboardingModal({
if (!isGitHubConnectedForOnboarding) {
setState("github-connection");
}
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]);
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -530,10 +573,14 @@ export function VercelOnboardingModal({
}, [completeOnboardingFetcher.data, completeOnboardingFetcher.state, state]);
useEffect(() => {
if (state === "completed") {
if (state === "completed" && !hasTrackedCompletionRef.current) {
hasTrackedCompletionRef.current = true;
trackOnboarding("vercel onboarding completed", {
github_connected: isGitHubConnectedForOnboarding,
});
onClose();
}
}, [state, onClose]);
}, [state, onClose, trackOnboarding, isGitHubConnectedForOnboarding]);
useEffect(() => {
if (state === "installing") {
@@ -565,6 +612,12 @@ export function VercelOnboardingModal({
}
}, [state, customEnvironments, vercelStagingEnvironment]);
useEffect(() => {
if (state === "project-selection" && availableProjects.length > 0 && !selectedVercelProject) {
setSelectedVercelProject(availableProjects[0]);
}
}, [state, availableProjects, selectedVercelProject]);
if (!isOpen || onboardingData?.authInvalid) {
return null;
}
@@ -578,7 +631,14 @@ export function VercelOnboardingModal({
if (isLoadingState) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && !fromMarketplaceContext && onClose()}>
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
if (state as string !== "completed") {
trackOnboarding("vercel onboarding abandoned");
}
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
@@ -601,7 +661,14 @@ export function VercelOnboardingModal({
const showGitHubConnection = state === "github-connection";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && !fromMarketplaceContext && onClose()}>
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
if (state !== "completed") {
trackOnboarding("vercel onboarding abandoned");
}
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
@@ -625,6 +692,7 @@ export function VercelOnboardingModal({
</Callout>
) : (
<Select
disabled={availableProjects.length === 1}
value={selectedVercelProject?.id || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
@@ -634,6 +702,7 @@ export function VercelOnboardingModal({
}
}}
items={availableProjects}
filter={availableProjects.length > 5 ? { keys: ["name"] } : undefined}
variant="tertiary/medium"
placeholder="Select a Vercel project"
dropdownIcon
@@ -894,6 +963,10 @@ export function VercelOnboardingModal({
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding env vars configured", {
env_vars_enabled: enabledEnvVars.length,
env_vars_total: syncableEnvVars.length,
});
if (fromMarketplaceContext) {
handleBuildSettingsNext();
} else {
@@ -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({
+127 -352
View File
@@ -1,43 +1,30 @@
import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon } from "@heroicons/react/20/solid";
import { Link } from "@remix-run/react";
import {
type MachinePresetName,
formatDurationMilliseconds,
} from "@trigger.dev/core/v3";
import { XMarkIcon } from "@heroicons/react/20/solid";
import type { TaskRunStatus } from "@trigger.dev/database";
import { useEffect, useState } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import * as Property from "~/components/primitives/PropertyTable";
import { TextLink } from "~/components/primitives/TextLink";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { SimpleTooltip, InfoIconTooltip } from "~/components/primitives/Tooltip";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import {
TaskRunStatusCombo,
descriptionForTaskRunStatus,
} from "~/components/runs/v3/TaskRunStatus";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { getLevelColor } from "~/utils/logUtils";
import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder";
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { RunTag } from "~/components/runs/v3/RunTag";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import type { TaskRunStatus } from "@trigger.dev/database";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import type { RunContext } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run";
type RunContextData = {
run: RunContext | null;
};
import { cn } from "~/utils/cn";
import { getLevelColor } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { LogLevel } from "./LogLevel";
import { ExitIcon } from "~/assets/icons/ExitIcon";
type LogDetailViewProps = {
logId: string;
// If we have the log entry from the list, we can display it immediately
@@ -46,27 +33,38 @@ type LogDetailViewProps = {
searchTerm?: string;
};
type TabType = "details" | "run";
type LogAttributes = Record<string, unknown> & {
error?: {
message?: string;
};
};
function getDisplayMessage(log: {
message: string;
level: string;
attributes?: LogAttributes;
}): string {
let message = log.message ?? "";
if (log.level === "ERROR") {
const maybeErrorMessage = log.attributes?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
return message;
}
function formatStringJSON(str: string): string {
return str
.replace(/\\n/g, "\n") // Converts literal "\n" to newline
.replace(/\\t/g, "\t"); // Converts literal "\t" to tab
}
export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof logDetailLoader>();
const [activeTab, setActiveTab] = useState<TabType>("details");
const [error, setError] = useState<string | null>(null);
// Fetch full log details when logId changes
@@ -75,7 +73,9 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
setError(null);
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}`
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/logs/${encodeURIComponent(logId)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, logId]);
@@ -93,6 +93,7 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
const isLoading = fetcher.state === "loading";
const log = fetcher.data ?? initialLog;
const runStatus = fetcher.data?.runStatus;
const runPath = v3RunSpanPath(
organization,
@@ -102,27 +103,6 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
{ spanId: log?.spanId ?? "" }
);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.contentEditable === "true"
)) {
return;
}
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, log, runPath, isLoading]);
if (isLoading && !log) {
return (
<div className="flex h-full items-center justify-center">
@@ -134,11 +114,16 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
if (!log) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-grid-dimmed p-4">
<div className="flex items-center justify-between border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2>Log Details</Header2>
<Button variant="minimal/small" onClick={onClose}>
<XMarkIcon className="size-5" />
</Button>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="flex flex-1 items-center justify-center">
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
@@ -148,103 +133,112 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-2 py-2">
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
<XMarkIcon className="size-5" />
</Button>
<div className="flex items-center justify-between overflow-hidden border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2 className="truncate">{getDisplayMessage(log)}</Header2>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
{/* Tabs */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-4">
<TabContainer>
<TabButton
isActive={activeTab === "details"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("details")}
shortcut={{ key: "d" }}
>
Details
</TabButton>
<TabButton
isActive={activeTab === "run"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("run")}
shortcut={{ key: "r" }}
>
Run
</TabButton>
</TabContainer>
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" LeadingIcon={ArrowTopRightOnSquareIcon} shortcut={{ key: "v" }}>
View full run
</Button>
</Link>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "details" && (
<DetailsTab log={log} runPath={runPath} searchTerm={searchTerm} />
)}
{activeTab === "run" && (
<RunTab log={log} runPath={runPath} />
)}
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<DetailsTab log={log} runPath={runPath} runStatus={runStatus} searchTerm={searchTerm} />
</div>
</div>
);
}
function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) {
const logWithExtras = log as LogEntry & {
function DetailsTab({
log,
runPath,
runStatus,
searchTerm,
}: {
log: LogEntry & {
attributes?: LogAttributes;
};
runPath: string;
runStatus?: TaskRunStatus;
searchTerm?: string;
}) {
let beautifiedAttributes: string | null = null;
if (logWithExtras.attributes) {
beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2);
if (log.attributes) {
beautifiedAttributes = JSON.stringify(log.attributes, null, 2);
beautifiedAttributes = formatStringJSON(beautifiedAttributes);
}
const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
// Determine message to show
let message = log.message ?? "";
if (log.level === "ERROR") {
const maybeErrorMessage = logWithExtras.attributes?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
const message = getDisplayMessage(log);
return (
<>
{/* Time */}
<div className="mb-6">
<Header3 className="mb-2">Timestamp</Header3>
<div className="text-sm text-text-dimmed">
<DateTimeAccurate date={log.startTime} />
</div>
</div>
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={log.runId} copyValue={log.runId} asChild />
<LinkButton
to={runPath}
variant="tertiary/small"
shortcut={{ key: "v" }}
className="mt-2"
>
View full run
</LinkButton>
</Property.Value>
</Property.Item>
{runStatus && (
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runStatus} />}
content={descriptionForTaskRunStatus(runStatus)}
disableHoverableContent
className="mt-1"
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText value={log.taskIdentifier} copyValue={log.taskIdentifier} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Level</Property.Label>
<Property.Value>
<LogLevel level={log.level} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Timestamp</Property.Label>
<Property.Value>
<DateTimeAccurate date={log.triggeredTimestamp} />
</Property.Value>
</Property.Item>
</Property.Table>
{/* Message */}
<div className="mb-6">
<div className="mb-6 mt-3">
<PacketDisplay
data={message}
dataType="application/json"
title="Message"
searchTerm={searchTerm}
wrap={true}
/>
</div>
@@ -262,222 +256,3 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri
</>
);
}
function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<RunContextData>();
// Fetch run details when tab is active
useEffect(() => {
if (!log.runId) return;
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, log.id, log.runId]);
const isLoading = fetcher.state === "loading";
const runData = fetcher.data?.run;
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
);
}
if (!runData) {
return (
<div className="flex flex-col items-center justify-center py-8">
<Paragraph className="text-text-dimmed">Run not found in database.</Paragraph>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-3">
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={runData.friendlyId} copyValue={runData.friendlyId} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runData.status as TaskRunStatus} />}
content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText
value={runData.taskIdentifier}
copyValue={runData.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
{runData.rootRun && (
<Property.Item>
<Property.Label>Root and parent run</Property.Label>
<Property.Value>
<CopyableText
value={runData.rootRun.taskIdentifier}
copyValue={runData.rootRun.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
)}
{runData.batch && (
<Property.Item>
<Property.Label>Batch</Property.Label>
<Property.Value>
<CopyableText
value={runData.batch.friendlyId}
copyValue={runData.batch.friendlyId}
asChild
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{runData.version ? (
environment.type === "DEVELOPMENT" ? (
<CopyableText value={runData.version} copyValue={runData.version} asChild />
) : (
<SimpleTooltip
button={
<TextLink
to={v3DeploymentVersionPath(
organization,
project,
environment,
runData.version
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
<CopyableText value={runData.version} copyValue={runData.version} asChild />
</TextLink>
}
content={"Jump to deployment"}
/>
)
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Test run</Property.Label>
<Property.Value>
{runData.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentCombo environment={environment} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue</Property.Label>
<Property.Value>
<div>Name: {runData.queue}</div>
<div>Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : ""}</div>
</Property.Value>
</Property.Item>
{runData.tags && runData.tags.length > 0 && (
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
{runData.tags.map((tag: string) => (
<RunTag
key={tag}
tag={tag}
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
tooltip={`Filter runs by ${tag}`}
/>
))}
</div>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Machine</Property.Label>
<Property.Value className="-ml-0.5">
{runData.machinePreset ? (
<MachineLabelCombo preset={runData.machinePreset as MachinePresetName} />
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Run invocation cost</Property.Label>
<Property.Value>
{runData.baseCostInCents > 0
? formatCurrencyAccurate(runData.baseCostInCents / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Compute cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 ? formatCurrencyAccurate(runData.costInCents / 100) : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 || runData.baseCostInCents > 0
? formatCurrencyAccurate((runData.baseCostInCents + runData.costInCents) / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Usage duration</Property.Label>
<Property.Value>
{runData.usageDurationMs > 0
? formatDurationMilliseconds(runData.usageDurationMs, { style: "short" })
: ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
);
}
@@ -0,0 +1,16 @@
import { cn } from "~/utils/cn";
import { getLevelColor } from "~/utils/logUtils";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
export function LogLevel({ level }: { level: LogEntry["level"] }) {
return (
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(level)
)}
>
{level}
</span>
);
}
@@ -16,6 +16,7 @@ import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
import { cn } from "~/utils/cn";
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
{ level: "TRACE", label: "Trace", color: "text-purple-400" },
{ level: "INFO", label: "Info", color: "text-blue-400" },
{ level: "WARN", label: "Warning", color: "text-warning" },
{ level: "ERROR", label: "Error", color: "text-error" },
@@ -33,6 +34,8 @@ function getLevelBadgeColor(level: LogLevel): string {
return "text-error bg-error/10 border-error/20";
case "WARN":
return "text-warning bg-warning/10 border-warning/20";
case "TRACE":
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
case "DEBUG":
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
@@ -1,55 +1,49 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { Input } from "~/components/primitives/Input";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { cn } from "~/utils/cn";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
export function LogsSearchInput() {
const location = useOptimisticLocation();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const { value, replace, del } = useSearchParams();
// Get initial search value from URL
const searchParams = new URLSearchParams(location.search);
const initialSearch = searchParams.get("search") ?? "";
const initialSearch = value("search") ?? "";
const [text, setText] = useState(initialSearch);
const [isFocused, setIsFocused] = useState(false);
// Update text when URL search param changes (only when not focused to avoid overwriting user input)
useEffect(() => {
const params = new URLSearchParams(location.search);
const urlSearch = params.get("search") ?? "";
const urlSearch = value("search") ?? "";
if (urlSearch !== text && !isFocused) {
setText(urlSearch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
}, [value, text, isFocused]);
const handleSubmit = useCallback(() => {
const params = new URLSearchParams(location.search);
if (text.trim()) {
params.set("search", text.trim());
replace({ search: text.trim() });
} else {
params.delete("search");
del("search");
}
// Reset cursor when searching
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [text, location.pathname, location.search, navigate]);
}, [text, replace, del]);
const handleClear = useCallback(() => {
setText("");
const params = new URLSearchParams(location.search);
params.delete("search");
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [location.pathname, location.search, navigate]);
const handleClear = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setText("");
del(["search", "cursor", "direction"]);
},
[del]
);
return (
<div className="flex items-center gap-1">
@@ -71,7 +65,7 @@ export function LogsSearchInput() {
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
className={cn("", isFocused && "placeholder:text-text-dimmed/70")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -86,22 +80,21 @@ export function LogsSearchInput() {
icon={<MagnifyingGlassIcon className="size-4" />}
accessory={
text.length > 0 ? (
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<div className="-mr-1 flex items-center gap-1">
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<button
type="button"
onClick={handleClear}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-3" />
</button>
</div>
) : undefined
}
/>
</motion.div>
{text.length > 0 && (
<button
type="button"
onClick={handleClear}
className="flex size-6 items-center justify-center rounded text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-4" />
</button>
)}
</div>
);
}
+15 -23
View File
@@ -2,16 +2,17 @@ import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/so
import { Link } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { getLevelColor, highlightSearchText } from "~/utils/logUtils";
import { highlightSearchText } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { DateTimeAccurate } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import { LogLevel } from "./LogLevel";
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
import {
@@ -48,14 +49,14 @@ function getLevelBoxShadow(level: LogEntry["level"]): string {
return "inset 2px 0 0 0 rgb(234, 179, 8)";
case "INFO":
return "inset 2px 0 0 0 rgb(59, 130, 246)";
case "TRACE":
return "inset 2px 0 0 0 rgb(168, 85, 247)";
case "DEBUG":
default:
return "none";
}
}
export function LogsTable({
logs,
searchTerm,
@@ -162,7 +163,7 @@ export function LogsTable({
boxShadow: getLevelBoxShadow(log.level),
}}
>
<DateTimeAccurate date={log.startTime} />
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
</TableCell>
<TableCell className="min-w-24">
<TruncatedCopyableValue value={log.runId} />
@@ -171,14 +172,7 @@ export function LogsTable({
<span className="font-mono text-xs">{log.taskIdentifier}</span>
</TableCell>
<TableCell onClick={handleRowClick} hasAction>
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
<LogLevel level={log.level} />
</TableCell>
<TableCell className="max-w-0 truncate" onClick={handleRowClick} hasAction>
<span className="block truncate font-mono text-xs" title={log.message}>
@@ -188,11 +182,13 @@ export function LogsTable({
<TableCellMenu
className="pl-32"
hiddenButtons={
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" TrailingIcon={ArrowTopRightOnSquareIcon}>
View run
</Button>
</Link>
<LinkButton
to={runPath}
variant="minimal/small"
TrailingIcon={ArrowTopRightOnSquareIcon}
>
View run
</LinkButton>
}
/>
</TableRow>
@@ -233,11 +229,7 @@ function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?:
No logs match your filters. Try refreshing or modifying your filters.
</Paragraph>
<div className="flex items-center gap-2">
<Button
LeadingIcon={ArrowPathIcon}
variant="tertiary/medium"
onClick={handleRefresh}
>
<Button LeadingIcon={ArrowPathIcon} variant="tertiary/medium" onClick={handleRefresh}>
Refresh
</Button>
</div>
@@ -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,523 @@
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, useRef, useState, type ReactNode } from "react";
import { z } from "zod";
import { Card } from "~/components/primitives/charts/Card";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
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;
/** Additional className applied to the Card wrapper */
className?: string;
/** 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;
/** When true, show table column headers even when there are no rows */
showTableHeaderOnEmpty?: boolean;
};
export function QueryWidget({
title,
titleString,
query,
accessory,
isLoading,
error,
isResizing,
isDraggable,
className,
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 containerRef = useRef<HTMLDivElement>(null);
const hasEditActions = onEdit || onRename || onDelete || onDuplicate;
const hasData = props.data.rows.length > 0;
// "v" to toggle fullscreen on hovered widget
useShortcutKeys({
shortcut: { key: "v" },
action: useCallback(() => {
const isHovered = containerRef.current?.matches(":hover");
if (!isFullscreen && !isHovered) return;
setIsFullscreen((prev) => !prev);
}, [isFullscreen]),
});
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 ref={containerRef} className="group h-full">
<Card className={cn("h-full overflow-hidden px-0 pb-0", className)}>
<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={
<span className="flex items-center gap-1">
Maximize
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
</span>
}
asChild
/>
<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>
{accessory}
</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;
showTableHeaderOnEmpty?: boolean;
};
function QueryWidgetBody({
title,
data,
config,
timeRange,
isFullscreen,
setIsFullscreen,
isLoading,
showTableHeaderOnEmpty,
}: 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}
showHeaderOnEmpty={showTableHeaderOnEmpty}
/>
<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}
showHeaderOnEmpty={showTableHeaderOnEmpty}
/>
</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,181 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher, useNavigate } from "@remix-run/react";
import { IconCheck } 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"
)}
>
{selectedDashboardId === dashboard.friendlyId ? (
<IconCheck className="size-4 shrink-0 text-green-500" />
) : (
<span className="size-4 shrink-0" />
)}
<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>
);
}
@@ -141,7 +141,14 @@ export function OrganizationSettingsSideMenu({
<div className="flex flex-col gap-1">
<SideMenuHeader title="Git ref" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
{buildInfo.gitRefName}
<a
href={`https://github.com/triggerdotdev/trigger.dev/tree/${buildInfo.gitRefName}`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-text-bright"
>
{buildInfo.gitRefName}
</a>
</Paragraph>
</div>
)}
@@ -149,7 +156,14 @@ export function OrganizationSettingsSideMenu({
<div className="flex flex-col gap-1">
<SideMenuHeader title="Git sha" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
{buildInfo.gitSha.slice(0, 9)}
<a
href={`https://github.com/triggerdotdev/trigger.dev/commit/${buildInfo.gitSha}`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-text-bright"
>
{buildInfo.gitSha.slice(0, 9)}
</a>
</Paragraph>
</div>
)}
+384 -308
View File
@@ -20,11 +20,11 @@ import {
ServerStackIcon,
Squares2X2Icon,
TableCellsIcon,
UsersIcon
UsersIcon,
} from "@heroicons/react/20/solid";
import { Link, useFetcher, useNavigation } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import simplur from "simplur";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
@@ -40,9 +40,8 @@ import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useHasAdminAccess } from "~/hooks/useUser";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { useHasAdminAccess } from "~/hooks/useUser";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
@@ -65,6 +64,7 @@ import {
v3ApiKeysPath,
v3BatchesPath,
v3BillingPath,
v3BuiltInDashboardPath,
v3BulkActionsPath,
v3DeploymentsPath,
v3EnvironmentPath,
@@ -88,23 +88,39 @@ import { ImpersonationBanner } from "../ImpersonationBanner";
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
import { Dialog, DialogTrigger } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverTrigger
} from "../primitives/Popover";
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { TextLink } from "../primitives/TextLink";
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../primitives/Tooltip";
import { ShortcutsAutoOpen } from "../Shortcuts";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { CreateDashboardButton } from "./DashboardDialogs";
import { DashboardList } from "./DashboardList";
import { EnvironmentSelector } from "./EnvironmentSelector";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import { type SideMenuSectionId } from "./sideMenuTypes";
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
/** Get the collapsed state for a specific side menu section from user preferences */
function getSectionCollapsed(
sideMenu: { collapsedSections?: Record<string, boolean> } | undefined,
sectionId: SideMenuSectionId
): boolean {
return sideMenu?.collapsedSections?.[sectionId] ?? false;
}
type SideMenuUser = Pick<
UserWithDashboardPreferences,
"email" | "admin" | "dashboardPreferences"
> & {
isImpersonating: boolean;
};
export type SideMenuProject = Pick<
@@ -138,7 +154,8 @@ export function SideMenu({
const preferencesFetcher = useFetcher();
const pendingPreferencesRef = useRef<{
isCollapsed?: boolean;
manageSectionCollapsed?: boolean;
sectionId?: SideMenuSectionId;
sectionCollapsed?: boolean;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentPlan = useCurrentPlan();
@@ -149,7 +166,11 @@ export function SideMenu({
const featureFlags = useFeatureFlags();
const persistSideMenuPreferences = useCallback(
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
(data: {
isCollapsed?: boolean;
sectionId?: SideMenuSectionId;
sectionCollapsed?: boolean;
}) => {
if (user.isImpersonating) return;
// Merge with any pending changes
@@ -170,8 +191,9 @@ export function SideMenu({
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
formData.append("sectionId", pending.sectionId);
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
@@ -191,13 +213,18 @@ export function SideMenu({
}
if (user.isImpersonating) return;
const pending = pendingPreferencesRef.current;
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
const hasPendingChanges =
pending.isCollapsed !== undefined ||
(pending.sectionId !== undefined && pending.sectionCollapsed !== undefined);
if (hasPendingChanges) {
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
formData.append("sectionId", pending.sectionId);
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
@@ -214,9 +241,10 @@ export function SideMenu({
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
};
const handleManageSectionToggle = useCallback(
(collapsed: boolean) => {
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
/** Generic handler for any collapsible section - just pass the section ID */
const handleSectionToggle = useCallback(
(sectionId: SideMenuSectionId) => (collapsed: boolean) => {
persistSideMenuPreferences({ sectionId, sectionCollapsed: collapsed });
},
[persistSideMenuPreferences]
);
@@ -255,294 +283,341 @@ 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}
/>
<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")}
>
{(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="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 +965,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 +989,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 +1002,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 +1018,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 +1061,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>
@@ -51,6 +51,7 @@ const ClientTabs = React.forwardRef<
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
activationMode="manual"
onValueChange={handleValueChange}
{...controlledProps}
{...props}
@@ -96,6 +97,7 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
@@ -134,6 +136,7 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
@@ -143,7 +146,7 @@ const ClientTabsTrigger = React.forwardRef<
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
)}
>
{children}
@@ -170,8 +173,9 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
@@ -188,9 +192,11 @@ const ClientTabsContent = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
tabIndex={-1}
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
"mt-1 outline-none",
className,
"data-[state=inactive]:hidden"
)}
{...props}
/>
@@ -196,7 +196,11 @@ export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: Date
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12);
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
// Helper function to check if two dates are on the same day
@@ -270,14 +274,18 @@ const DateTimeAccurateInner = ({
return hideDate
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
if (!showTooltip)
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
const tooltipContent = (
<TooltipContent
@@ -290,7 +298,11 @@ const DateTimeAccurateInner = ({
return (
<SimpleTooltip
button={<span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>}
button={
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
}
content={tooltipContent}
side="right"
asChild={true}
@@ -326,9 +338,13 @@ function formatDateTimeAccurate(
locales: string[],
hour12: boolean = true
): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
const datePart = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
timeZone,
}).format(date);
const timePart = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
second: "numeric",
@@ -338,7 +354,7 @@ function formatDateTimeAccurate(
hour12,
}).format(date);
return formattedDateTime;
return `${datePart} ${timePart}`;
}
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
@@ -347,7 +363,11 @@ export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
const realDate = typeof date === "string" ? new Date(date) : date;
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
function formatDateTimeShort(
@@ -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) */}
@@ -1,5 +1,6 @@
import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { cn } from "~/utils/cn";
const sizes = {
@@ -63,7 +64,7 @@ const variants = {
type VariantType = keyof typeof variants;
type Options = {
label: string;
label: ReactNode;
value: string;
};
@@ -8,12 +8,15 @@ import { cn } from "~/utils/cn";
import { useOperatingSystem } from "./OperatingSystemProvider";
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
const small =
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border transition uppercase";
const medium =
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small:
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
small: cn(small, "border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60"),
"small/bright": cn(small, "bg-charcoal-750 text-text-bright border-charcoal-650"),
medium: cn(medium, "group-hover:border-charcoal-550"),
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
};
@@ -54,10 +57,10 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
);
}
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
function keyString(key: string, isMac: boolean, variant: ShortcutKeyVariant) {
key = key.toLowerCase();
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-2.5 h-4.5";
switch (key) {
case "enter":
@@ -86,9 +89,9 @@ function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "m
function modifierString(
modifier: Modifier,
isMac: boolean,
variant: "small" | "medium" | "medium/bright"
variant: ShortcutKeyVariant
): string | JSX.Element {
const className = variant === "small" ? "w-2.5 h-4" : "w-3.5 h-5";
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-3.5 h-5";
switch (modifier) {
case "alt":
@@ -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,166 @@
import type { OutputColumnMetadata } from "@internal/tsql";
import { Hash } from "lucide-react";
import { useMemo } from "react";
import type {
BigNumberAggregationType,
BigNumberConfiguration,
} from "~/components/metrics/QueryWidget";
import { AnimatedNumber } from "../AnimatedNumber";
import { ChartBlankState } from "./ChartBlankState";
import { Spinner } from "../Spinner";
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 <ChartBlankState icon={Hash} message="No data to display" />;
}
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,16 @@ 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 pl-4 pr-3",
draggable && "cursor-grab active:cursor-grabbing"
)}
>
{children}
</Header3>
);
};
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from "react";
import React, { useCallback } from "react";
import {
Bar,
BarChart,
@@ -11,20 +11,13 @@ import {
type XAxisProps,
type YAxisProps,
} from "recharts";
import {
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
type ChartState,
} from "~/components/primitives/charts/Chart";
import { cn } from "~/utils/cn";
import { ChartBarLoading, ChartBarInvalid, ChartBarNoData } from "./ChartLoading";
import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart";
import { useChartContext } from "./ChartContext";
import { ChartRoot, useHasNoData } from "./ChartRoot";
import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading";
import { useHasNoData } from "./ChartRoot";
// Legend is now rendered by ChartRoot outside the chart container
import { ZoomTooltip, useZoomHandlers } from "./ChartZoom";
import { getBarOpacity } from "./hooks/useHighlightState";
import type { ZoomRange } from "./hooks/useZoomSelection";
//TODO: fix the first and last bars in a stack not having rounded corners
@@ -162,26 +155,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 && (
@@ -0,0 +1,23 @@
import { cn } from "~/utils/cn";
import { Paragraph } from "../Paragraph";
export function ChartBlankState({
icon: Icon,
message,
className,
}: {
icon?: React.ComponentType<{ className?: string }>;
message: string;
className?: string;
}) {
return (
<div className={cn("flex h-full w-full items-center justify-center", className)}>
<div className="-mt-3 flex flex-col items-center gap-2">
{Icon && <Icon className="size-12 text-charcoal-700" />}
<Paragraph variant="small" className="text-text-dimmed/70">
{message}
</Paragraph>
</div>
</div>
);
}
@@ -1,10 +1,19 @@
import React, { useMemo } from "react";
import type { AggregationType } from "~/components/metrics/QueryWidget";
import { useChartContext } from "./ChartContext";
import { useSeriesTotal } from "./ChartRoot";
import { Button } from "../Buttons";
import { Paragraph } from "../Paragraph";
import { aggregateValues } from "./aggregation";
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import { SimpleTooltip } from "../Tooltip";
const aggregationLabels: Record<AggregationType, string> = {
sum: "Sum",
avg: "Average",
count: "Count",
min: "Min",
max: "Max",
};
export type ChartLegendCompoundProps = {
/** Maximum number of legend items to show before collapsing */
@@ -13,8 +22,10 @@ export type ChartLegendCompoundProps = {
hidden?: boolean;
/** Additional className */
className?: string;
/** Label for the total row */
/** Label for the total row (derived from aggregation when not provided) */
totalLabel?: string;
/** Aggregation method controls the header label and how totals are computed */
aggregation?: AggregationType;
/** Callback when "View all" button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
@@ -37,57 +48,81 @@ export function ChartLegendCompound({
maxItems = Infinity,
hidden = false,
className,
totalLabel = "Total",
totalLabel,
aggregation,
onViewAllLegendItems,
scrollable = false,
}: ChartLegendCompoundProps) {
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
const totals = useSeriesTotal();
const totals = useSeriesTotal(aggregation);
// Calculate grand total (sum of all series totals)
// Derive the effective label from the aggregation type when no explicit label is provided
const effectiveTotalLabel = totalLabel ?? (aggregation ? aggregationLabels[aggregation] : "Total");
// Calculate grand total by aggregating across all per-series values
const grandTotal = useMemo(() => {
return dataKeys.reduce((sum, key) => sum + (totals[key] || 0), 0);
}, [totals, dataKeys]);
const values = dataKeys.map((key) => totals[key] || 0);
if (!aggregation) {
// Default: sum
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
}, [totals, dataKeys, aggregation]);
// Calculate current total based on hover state
const currentTotal = useMemo(() => {
// Calculate current total based on hover state (null when hovering a gap-filled point)
const currentTotal = useMemo((): number | null => {
if (!highlight.activePayload?.length) return grandTotal;
// Sum all values from the hovered data point
return highlight.activePayload.reduce((sum, item) => {
if (item.value !== undefined && dataKeys.includes(item.dataKey as string)) {
return sum + (Number(item.value) || 0);
}
return sum;
}, 0);
}, [highlight.activePayload, grandTotal, dataKeys]);
// Collect all series values from the hovered data point, preserving nulls
const rawValues = highlight.activePayload
.filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string))
.map((item) => item.value);
// Get the label for the total row - x-axis value when hovering, totalLabel otherwise
// Filter to non-null values only
const values = rawValues
.filter((v): v is number => v != null)
.map((v) => Number(v) || 0);
// All null → gap-filled point, return null to show dash
if (values.length === 0) return null;
if (!aggregation) {
// Default: sum
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
}, [highlight.activePayload, grandTotal, dataKeys, aggregation]);
// Get the label for the total row - x-axis value when hovering, effectiveTotalLabel otherwise
const currentTotalLabel = useMemo(() => {
if (!highlight.activePayload?.length) return totalLabel;
if (!highlight.activePayload?.length) return effectiveTotalLabel;
// Get the x-axis label from the payload's original data
const firstPayloadItem = highlight.activePayload[0];
const xAxisValue = firstPayloadItem?.payload?.[dataKey];
if (xAxisValue === undefined) return totalLabel;
if (xAxisValue === undefined) return effectiveTotalLabel;
// Apply the formatter if provided, otherwise just stringify the value
const stringValue = String(xAxisValue);
return labelFormatter ? labelFormatter(stringValue) : stringValue;
}, [highlight.activePayload, dataKey, totalLabel, labelFormatter]);
}, [highlight.activePayload, dataKey, effectiveTotalLabel, labelFormatter]);
// Get current data for the legend based on hover state
const currentData = useMemo(() => {
// Get current data for the legend based on hover state (values may be null for gap-filled points)
const currentData = useMemo((): Record<string, number | null> => {
if (!highlight.activePayload?.length) return totals;
// If we have activePayload data from hovering over a bar
const hoverData = highlight.activePayload.reduce((acc, item) => {
if (item.dataKey && item.value !== undefined) {
acc[item.dataKey] = Number(item.value) || 0;
}
return acc;
}, {} as Record<string, number>);
// If we have activePayload data from hovering over a bar/line
const hoverData = highlight.activePayload.reduce(
(acc, item) => {
if (item.dataKey && item.value !== undefined) {
// Preserve null for gap-filled points instead of coercing to 0
acc[item.dataKey] = item.value != null ? Number(item.value) || 0 : null;
}
return acc;
},
{} as Record<string, number | null>
);
// Return a merged object - totals for keys not in the hover data
return {
@@ -132,11 +167,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 px-2 pb-2 pt-4 text-sm", scrollable && "max-h-[50%] min-h-0", className)}
>
{/* Total row */}
<div
@@ -147,7 +178,11 @@ export function ChartLegendCompound({
>
<span className="font-medium">{currentTotalLabel}</span>
<span className="font-medium tabular-nums">
<AnimatedNumber value={currentTotal} duration={0.25} />
{currentTotal != null ? (
<AnimatedNumber value={currentTotal} duration={0.25} />
) : (
"\u2013"
)}
</span>
</div>
@@ -155,17 +190,23 @@ 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 total = currentData[item.dataKey] ?? null;
const isActive = highlight.activeBarKey === item.dataKey;
return (
<div
key={item.dataKey}
className={cn(
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
total === 0 && "opacity-50"
"relative flex w-full cursor-default items-center justify-between gap-2 rounded px-2 py-1 transition",
(total == null || total === 0) && "opacity-50"
)}
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
onMouseLeave={() => highlight.reset()}
@@ -177,25 +218,43 @@ export function ChartLegendCompound({
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{item.color && (
<div
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
{item.label}
</span>
</div>
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<SimpleTooltip
button={
<div className="flex min-w-0 items-center gap-1.5">
{item.color && (
<div
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span
className={cn(
"truncate",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
{item.label}
</span>
</div>
}
content={item.label}
side="top"
disableHoverableContent
className="max-w-xs break-words"
buttonClassName="cursor-default min-w-0"
/>
<span
className={cn(
"self-start tabular-nums",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
<AnimatedNumber value={total} duration={0.25} />
{total != null ? (
<AnimatedNumber value={total} duration={0.25} />
) : (
"\u2013"
)}
</span>
</div>
</div>
@@ -207,11 +266,14 @@ export function ChartLegendCompound({
(legendItems.hoveredHiddenItem ? (
<HoveredHiddenItemRow
item={legendItems.hoveredHiddenItem}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? null}
remainingCount={legendItems.remaining - 1}
/>
) : (
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
<ViewAllDataRow
remainingCount={legendItems.remaining}
onViewAll={onViewAllLegendItems}
/>
))}
</div>
</div>
@@ -225,29 +287,32 @@ 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>
);
}
type HoveredHiddenItemRowProps = {
item: { dataKey: string; color?: string; label: React.ReactNode };
value: number;
value: number | null;
remainingCount: number;
};
@@ -261,11 +326,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 }}
/>
)}
@@ -273,7 +338,7 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
</div>
<span className="tabular-nums text-text-bright">
<AnimatedNumber value={value} duration={0.25} />
{value != null ? <AnimatedNumber value={value} duration={0.25} /> : "\u2013"}
</span>
</div>
</div>
@@ -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
@@ -188,6 +186,7 @@ export function ChartLineRenderer({
width={width}
height={height}
margin={{
top: 5,
left: 12,
right: 12,
}}
@@ -205,14 +204,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
@@ -221,7 +218,7 @@ export function ChartLineRenderer({
type={lineType}
stroke={config[key]?.color}
strokeWidth={1}
dot={false}
dot={{ r: 1.5, fill: config[key]?.color, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
@@ -1,5 +1,6 @@
import React, { useMemo } from "react";
import type * as RechartsPrimitive from "recharts";
import type { AggregationType } from "~/components/metrics/QueryWidget";
import { ChartContainer, type ChartConfig, type ChartState } from "./Chart";
import { ChartProvider, useChartContext, type LabelFormatter } from "./ChartContext";
import { ChartLegendCompound } from "./ChartLegendCompound";
@@ -29,6 +30,8 @@ export type ChartRootProps = {
maxLegendItems?: number;
/** Label for the total row in the legend */
legendTotalLabel?: string;
/** Aggregation method used by the legend to compute totals (defaults to sum behavior) */
legendAggregation?: AggregationType;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
@@ -73,6 +76,7 @@ export function ChartRoot({
showLegend = false,
maxLegendItems = 5,
legendTotalLabel,
legendAggregation,
onViewAllLegendItems,
legendScrollable = false,
fillContainer = false,
@@ -96,6 +100,7 @@ export function ChartRoot({
showLegend={showLegend}
maxLegendItems={maxLegendItems}
legendTotalLabel={legendTotalLabel}
legendAggregation={legendAggregation}
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
fillContainer={fillContainer}
@@ -112,6 +117,7 @@ type ChartRootInnerProps = {
showLegend?: boolean;
maxLegendItems?: number;
legendTotalLabel?: string;
legendAggregation?: AggregationType;
onViewAllLegendItems?: () => void;
legendScrollable?: boolean;
fillContainer?: boolean;
@@ -124,6 +130,7 @@ function ChartRootInner({
showLegend = false,
maxLegendItems = 5,
legendTotalLabel,
legendAggregation,
onViewAllLegendItems,
legendScrollable = false,
fillContainer = false,
@@ -165,6 +172,7 @@ function ChartRootInner({
<ChartLegendCompound
maxItems={maxLegendItems}
totalLabel={legendTotalLabel}
aggregation={legendAggregation}
onViewAllLegendItems={onViewAllLegendItems}
scrollable={legendScrollable}
/>
@@ -194,18 +202,79 @@ export function useHasNoData(): boolean {
}
/**
* Hook to calculate totals for each series across all data points.
* Hook to calculate aggregated values for each series across all data points.
* When no aggregation is provided, defaults to sum (original behavior).
* Useful for legend displays.
*/
export function useSeriesTotal(): Record<string, number> {
export function useSeriesTotal(aggregation?: AggregationType): Record<string, number> {
const { data, dataKeys } = useChartContext();
return useMemo(() => {
return data.reduce((acc, item) => {
for (const seriesKey of dataKeys) {
acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0);
// Sum (default) and count use additive accumulation
if (!aggregation || aggregation === "sum" || aggregation === "count") {
return data.reduce(
(acc, item) => {
for (const seriesKey of dataKeys) {
acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0);
}
return acc;
},
{} as Record<string, number>
);
}
if (aggregation === "avg") {
const sums: Record<string, number> = {};
const counts: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
const rawVal = item[seriesKey];
if (rawVal == null) continue; // skip gap-filled nulls
const val = Number(rawVal);
sums[seriesKey] = (sums[seriesKey] || 0) + val;
counts[seriesKey] = (counts[seriesKey] || 0) + 1;
}
}
return acc;
}, {} as Record<string, number>);
}, [data, dataKeys]);
const result: Record<string, number> = {};
for (const key of dataKeys) {
result[key] = counts[key] ? sums[key]! / counts[key]! : 0;
}
return result;
}
if (aggregation === "min") {
const result: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
if (item[seriesKey] == null) continue; // skip gap-filled nulls
const val = Number(item[seriesKey]);
if (result[seriesKey] === undefined || val < result[seriesKey]) {
result[seriesKey] = val;
}
}
}
// Default to 0 for series with no data
for (const key of dataKeys) {
if (result[key] === undefined) result[key] = 0;
}
return result;
}
// aggregation === "max"
const result: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
if (item[seriesKey] == null) continue; // skip gap-filled nulls
const val = Number(item[seriesKey]);
if (result[seriesKey] === undefined || val > result[seriesKey]) {
result[seriesKey] = val;
}
}
}
// Default to 0 for series with no data
for (const key of dataKeys) {
if (result[key] === undefined) result[key] = 0;
}
return result;
}, [data, dataKeys, aggregation]);
}
@@ -0,0 +1,23 @@
import type { AggregationType } from "~/components/metrics/QueryWidget";
/**
* Aggregate an array of numbers using the specified aggregation function.
*
* Shared utility so both QueryResultsChart (data transformation) and chart
* legend components can reuse the same logic without circular imports.
*/
export function aggregateValues(values: number[], aggregation: AggregationType): 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -12,11 +12,13 @@ export function PacketDisplay({
dataType,
title,
searchTerm,
wrap,
}: {
data: string;
dataType: string;
title: string;
searchTerm?: string;
wrap?: boolean;
}) {
switch (dataType) {
case "application/store": {
@@ -54,6 +56,7 @@ export function PacketDisplay({
showLineNumbers={false}
showTextWrapping
searchTerm={searchTerm}
wrap={wrap}
/>
);
}
@@ -67,6 +70,7 @@ export function PacketDisplay({
showLineNumbers={false}
showTextWrapping
searchTerm={searchTerm}
wrap={wrap}
/>
);
}
@@ -201,6 +201,7 @@ function ReplayForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -357,6 +358,35 @@ function ReplayForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{replayData.regions.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
<Select
{...conform.select(region)}
variant="tertiary/small"
placeholder={replayData.disableVersionSelection ? "" : undefined}
dropdownIcon
items={replayData.regions}
defaultValue={replayData.region ?? undefined}
disabled={replayData.disableVersionSelection}
>
{replayData.regions.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.description ? `${r.name}${r.description}` : r.name}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{replayData.disableVersionSelection ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -4,6 +4,7 @@ import {
InformationCircleIcon,
RectangleStackIcon,
Squares2X2Icon,
TableCellsIcon,
TagIcon,
} from "@heroicons/react/20/solid";
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
@@ -47,8 +48,6 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
) {
return <TablerIcon name={spanNameIcon.iconName} className={className} />;
}
<InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
}
if (!name) return <Squares2X2Icon className={cn(className, "text-text-dimmed")} />;
@@ -81,6 +80,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return <WaitpointTokenIcon className={cn(className, "text-sky-500")} />;
case "function":
return <FunctionIcon className={cn(className, "text-text-dimmed")} />;
case "query":
return <TableCellsIcon className={cn(className, "text-query")} />;
//log levels
case "debug":
case "log":
@@ -110,7 +111,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
case "task-hook-catchError":
return <FunctionIcon className={cn(className, "text-error")} />;
case "streams":
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
}
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
@@ -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,48 @@ export const timeFilters = ({
};
};
export function timeFilterFromTo(props: {
period?: string;
from?: string | number;
to?: string | number;
defaultPeriod: string;
}): { from: Date; to: Date; isDefault: boolean } {
const time = timeFilters(props);
const periodMs = time.period ? parse(time.period) : undefined;
if (periodMs) {
return {
from: new Date(Date.now() - periodMs),
to: new Date(),
isDefault: time.isDefault,
};
}
if (time.from && time.to) {
return {
from: time.from,
to: time.to,
isDefault: time.isDefault,
};
}
if (time.from) {
return {
from: time.from,
to: new Date(),
isDefault: time.isDefault,
};
}
const defaultPeriodMs = parse(props.defaultPeriod) ?? 24 * 60 * 60 * 1_000;
return {
from: new Date(Date.now() - defaultPeriodMs),
to: time.to ?? new Date(),
isDefault: time.isDefault,
};
}
export function timeFilterRenderValues({
from,
to,
@@ -257,7 +299,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 +326,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 +352,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 +366,7 @@ export function TimeFilter({
applyShortcut,
onValueChange,
maxPeriodDays,
valueClassName,
}: TimeFilterProps = {}) {
const { value } = useSearchParams();
const periodValue = period ?? value("period");
@@ -343,6 +393,7 @@ export function TimeFilter({
value={constrained.valueLabel}
removable={false}
variant="secondary/small"
valueClassName={valueClassName}
/>
</Ariakit.Select>
}
@@ -443,7 +494,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 {
@@ -452,33 +504,26 @@ export function TimeDropdown({
}
})();
const applySelection = useCallback(() => {
setValidationError(null);
const applyPeriod = useCallback(
(periodToApply: string) => {
setValidationError(null);
if (exceedsMaxPeriod) {
setValidationError(`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`);
return;
}
if (activeSection === "duration") {
// Validate custom duration
if (selectedPeriod === "custom" && !isCustomDurationValid) {
setValidationError("Please enter a valid custom duration");
if (maxPeriodDays && periodToDays(periodToApply) > maxPeriodDays) {
setValidationError(
`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`
);
return;
}
let periodToApply = selectedPeriod;
if (selectedPeriod === "custom") {
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
onValueChange(values);
} else {
// URL mode - navigate
replace({
period: periodToApply,
cursor: undefined,
@@ -492,6 +537,30 @@ export function TimeDropdown({
setToValue(undefined);
setOpen(false);
onApply?.(values);
},
[maxPeriodDays, onValueChange, replace, onApply]
);
const applySelection = useCallback(() => {
setValidationError(null);
if (exceedsMaxPeriod) {
setValidationError(
`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`
);
return;
}
if (activeSection === "duration") {
// Validate custom duration
if (selectedPeriod === "custom" && !isCustomDurationValid) {
setValidationError("Please enter a valid custom duration");
return;
}
const periodToApply =
selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
applyPeriod(periodToApply);
} else {
// Validate date range
if (!fromValue && !toValue) {
@@ -538,7 +607,8 @@ export function TimeDropdown({
onApply,
onValueChange,
exceedsMaxPeriod,
maxPeriodDays
maxPeriodDays,
applyPeriod,
]);
return (
@@ -579,9 +649,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()}
>
@@ -649,7 +719,7 @@ export function TimeDropdown({
setCustomValue(parsed.value.toString());
setCustomUnit(parsed.unit);
}
setValidationError(null);
applyPeriod(p.value);
}}
fullWidth
type="button"
@@ -800,7 +870,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 +887,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 +901,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();
@@ -12,6 +12,7 @@ import {
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { TaskRunStatus } from "@trigger.dev/database";
import { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3";
import assertNever from "assert-never";
import { HourglassIcon } from "lucide-react";
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
@@ -248,26 +249,9 @@ export function runStatusFromFriendlyTitle(friendly: RunFriendlyStatus): TaskRun
return result[0] as TaskRunStatus;
}
export const runFriendlyStatus = [
"Delayed",
"Queued",
"Pending version",
"Dequeued",
"Executing",
"Waiting",
"Reattempting",
"Paused",
"Canceled",
"Interrupted",
"Completed",
"Failed",
"System failure",
"Crashed",
"Expired",
"Timed out",
] as const;
export type RunFriendlyStatus = (typeof runFriendlyStatus)[number];
// runFriendlyStatus and RunFriendlyStatus are imported from @trigger.dev/core/v3
// and re-exported here for backward compatibility.
export { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3";
/**
* Check if a value is a valid TaskRunStatus
@@ -304,3 +288,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;
}
+15 -7
View File
@@ -1180,7 +1180,7 @@ const EnvironmentSchema = z
CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
// Logs List Query Settings (for paginated log views)
// Logs Query Settings
CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_000_000_000),
CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT: z.coerce
.number()
@@ -1190,15 +1190,20 @@ const EnvironmentSchema = z
CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ: z.coerce.number().int().default(10_000_000),
CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME: z.coerce.number().int().default(120),
// Logs Detail Query Settings (for single log views)
CLICKHOUSE_LOGS_DETAIL_MAX_MEMORY_USAGE: z.coerce.number().int().default(64_000_000),
CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2),
CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60),
// Query feature flag
QUERY_FEATURE_ENABLED: z.string().default("1"),
// Logs page ClickHouse URL (for logs queries)
LOGS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
// 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),
@@ -1208,7 +1213,10 @@ const EnvironmentSchema = z
// Query page concurrency limits
QUERY_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(3),
QUERY_GLOBAL_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
QUERY_GLOBAL_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
// Metric widget concurrency limits
METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(30),
EVENTS_CLICKHOUSE_URL: z
.string()
+515
View File
@@ -0,0 +1,515 @@
import { useReducer, useCallback, useRef, useEffect } from "react";
import { nanoid } from "nanoid";
import type {
DashboardLayout,
LayoutItem,
Widget,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import type { WidgetData, QueryWidgetConfig } from "~/components/metrics/QueryWidget";
// ============================================================================
// Types
// ============================================================================
type EditorMode =
| null
| { type: "add" }
| { type: "edit"; widgetId: string; widget: WidgetData };
type DashboardState = {
/** The layout items (positions/sizes) */
layout: LayoutItem[];
/** The widget configurations keyed by widget ID */
widgets: Record<string, Widget>;
/** Current editor mode (add/edit/closed) */
editorMode: EditorMode;
};
// ============================================================================
// Actions
// ============================================================================
type DashboardAction =
| { type: "ADD_WIDGET"; payload: { id: string; widget: Widget; layoutItem: LayoutItem } }
| { type: "UPDATE_WIDGET"; payload: { id: string; widget: Widget } }
| { type: "RENAME_WIDGET"; payload: { id: string; title: string } }
| { type: "DELETE_WIDGET"; payload: { id: string } }
| { type: "DUPLICATE_WIDGET"; payload: { id: string; newId: string } }
| { type: "UPDATE_LAYOUT"; payload: { layout: LayoutItem[] } }
| { type: "RESET_STATE"; payload: { layout: LayoutItem[]; widgets: Record<string, Widget> } }
| { type: "OPEN_ADD_EDITOR" }
| { type: "OPEN_EDIT_EDITOR"; payload: { widgetId: string; widget: WidgetData } }
| { type: "CLOSE_EDITOR" };
// ============================================================================
// Reducer
// ============================================================================
function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
switch (action.type) {
case "ADD_WIDGET":
return {
...state,
layout: [...state.layout, action.payload.layoutItem],
widgets: {
...state.widgets,
[action.payload.id]: action.payload.widget,
},
editorMode: null,
};
case "UPDATE_WIDGET":
return {
...state,
widgets: {
...state.widgets,
[action.payload.id]: action.payload.widget,
},
editorMode: null,
};
case "RENAME_WIDGET": {
const existingWidget = state.widgets[action.payload.id];
if (!existingWidget) return state;
return {
...state,
widgets: {
...state.widgets,
[action.payload.id]: {
...existingWidget,
title: action.payload.title,
},
},
};
}
case "DELETE_WIDGET": {
const { [action.payload.id]: _, ...remainingWidgets } = state.widgets;
return {
...state,
layout: state.layout.filter((item) => item.i !== action.payload.id),
widgets: remainingWidgets,
};
}
case "DUPLICATE_WIDGET": {
const original = state.widgets[action.payload.id];
const originalLayout = state.layout.find((l) => l.i === action.payload.id);
if (!original || !originalLayout) return state;
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
// Deep copy the widget to ensure no shared references
// This prevents edits to one widget from affecting the duplicate
const duplicatedWidget: Widget = {
title: `${original.title} (Copy)`,
query: original.query,
display: JSON.parse(JSON.stringify(original.display)) as QueryWidgetConfig,
};
return {
...state,
layout: [
...state.layout,
{ ...originalLayout, i: action.payload.newId, y: maxBottom, x: 0 },
],
widgets: {
...state.widgets,
[action.payload.newId]: duplicatedWidget,
},
};
}
case "UPDATE_LAYOUT":
return { ...state, layout: action.payload.layout };
case "RESET_STATE":
return {
...state,
layout: action.payload.layout,
widgets: action.payload.widgets,
};
case "OPEN_ADD_EDITOR":
return { ...state, editorMode: { type: "add" } };
case "OPEN_EDIT_EDITOR":
return {
...state,
editorMode: { type: "edit", widgetId: action.payload.widgetId, widget: action.payload.widget },
};
case "CLOSE_EDITOR":
return { ...state, editorMode: null };
default:
return state;
}
}
// ============================================================================
// Hook Options
// ============================================================================
export type UseDashboardEditorOptions = {
/** Initial dashboard layout data from the server */
initialData: DashboardLayout;
/** URL for widget actions (add, update, delete, duplicate) */
widgetActionUrl: string;
/** URL for layout updates. If empty or not provided, uses current page URL. */
layoutActionUrl?: string;
/** Maximum number of widgets allowed per dashboard. If not provided, no limit is enforced. */
widgetLimit?: number;
/** Callback when a sync error occurs */
onSyncError?: (error: Error, action: string) => void;
/** Callback when a widget action is blocked by the limit */
onWidgetLimitReached?: () => void;
};
// ============================================================================
// Sync Queue Types
// ============================================================================
type WidgetSyncTask = {
type: "widget";
action: string;
data: Record<string, string>;
};
type LayoutSyncTask = {
type: "layout";
layout: LayoutItem[];
};
type SyncTask = WidgetSyncTask | LayoutSyncTask;
// ============================================================================
// Hook
// ============================================================================
export function useDashboardEditor({
initialData,
widgetActionUrl,
layoutActionUrl,
widgetLimit,
onSyncError,
onWidgetLimitReached,
}: UseDashboardEditorOptions) {
const [state, dispatch] = useReducer(dashboardReducer, {
layout: initialData.layout,
widgets: initialData.widgets,
editorMode: null,
});
// Refs for debouncing and tracking initialization
const layoutDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isInitializedRef = useRef(false);
const currentLayoutJsonRef = useRef<string>(JSON.stringify(initialData.layout));
// Sync queue to prevent race conditions
const syncQueueRef = useRef<SyncTask[]>([]);
const isSyncingRef = useRef(false);
// Reset state when initialData changes (e.g., navigating to different dashboard)
const initialDataJson = JSON.stringify({ layout: initialData.layout, widgets: initialData.widgets });
useEffect(() => {
// Cancel any pending layout save
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
layoutDebounceRef.current = null;
}
// Clear the sync queue when switching dashboards
syncQueueRef.current = [];
// Reset state to new initial data
dispatch({
type: "RESET_STATE",
payload: { layout: initialData.layout, widgets: initialData.widgets },
});
// Update refs
currentLayoutJsonRef.current = JSON.stringify(initialData.layout);
isInitializedRef.current = false;
// Allow saves after a short delay to skip initial mount callbacks
const initTimeout = setTimeout(() => {
isInitializedRef.current = true;
}, 100);
return () => {
clearTimeout(initTimeout);
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
}
};
}, [initialDataJson]);
// -------------------------------------------------------------------------
// Sync queue processor - ensures only one sync runs at a time
// -------------------------------------------------------------------------
const processNextSync = useCallback(async () => {
// If already syncing or queue is empty, do nothing
if (isSyncingRef.current || syncQueueRef.current.length === 0) {
return;
}
isSyncingRef.current = true;
const task = syncQueueRef.current.shift()!;
try {
if (task.type === "widget") {
const formData = new FormData();
formData.set("action", task.action);
Object.entries(task.data).forEach(([k, v]) => formData.set(k, v));
const response = await fetch(widgetActionUrl, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to ${task.action} widget: ${errorText}`);
}
} else if (task.type === "layout") {
const formData = new FormData();
formData.set("action", "layout");
formData.set("layout", JSON.stringify(task.layout));
// Use current page URL if layoutActionUrl is not provided
const url = layoutActionUrl || window.location.pathname;
const response = await fetch(url, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error("Failed to update layout: " + errorText);
}
}
} catch (error) {
console.error(`Dashboard sync error:`, error);
const actionName = task.type === "widget" ? task.action : "layout";
onSyncError?.(error instanceof Error ? error : new Error(String(error)), actionName);
} finally {
isSyncingRef.current = false;
// Process next item in queue
processNextSync();
}
}, [widgetActionUrl, layoutActionUrl, onSyncError]);
// -------------------------------------------------------------------------
// Queue helpers
// -------------------------------------------------------------------------
const queueWidgetSync = useCallback(
(action: string, data: Record<string, string>) => {
syncQueueRef.current.push({ type: "widget", action, data });
processNextSync();
},
[processNextSync]
);
const queueLayoutSync = useCallback(
(layout: LayoutItem[]) => {
// For layout syncs, we only care about the latest state
// Remove any pending layout syncs and add the new one
syncQueueRef.current = syncQueueRef.current.filter((task) => task.type !== "layout");
syncQueueRef.current.push({ type: "layout", layout });
processNextSync();
},
[processNextSync]
);
// -------------------------------------------------------------------------
// Action handlers
// -------------------------------------------------------------------------
// Count only non-title widgets for limit checks (title widgets are free)
const countedWidgets = Object.values(state.widgets).filter(
(w) => w.display.type !== "title"
).length;
const addWidget = useCallback(
(title: string, query: string, config: QueryWidgetConfig) => {
// Guard: check widget limit (title widgets don't count)
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
onWidgetLimitReached?.();
return;
}
const id = nanoid(8);
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 15 };
const widget: Widget = { title, query, display: config };
// Update local state immediately
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
// Queue sync to server (processed sequentially)
// Send the client-generated ID so the server uses the same ID
queueWidgetSync("add", {
widgetId: id,
title,
query,
config: JSON.stringify(config),
});
},
[state.layout, countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
);
const addTitleWidget = useCallback(
(title: string) => {
const id = nanoid(8);
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
// Title widgets are fixed at h=2 and full width
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 2 };
const config: QueryWidgetConfig = { type: "title" };
const widget: Widget = { title, query: "", display: config };
// Update local state immediately
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
// Queue sync to server (processed sequentially)
// Send the client-generated ID so the server uses the same ID
queueWidgetSync("add", {
widgetId: id,
title,
query: "",
config: JSON.stringify(config),
});
},
[state.layout, queueWidgetSync]
);
const updateWidget = useCallback(
(widgetId: string, title: string, query: string, config: QueryWidgetConfig) => {
const widget: Widget = { title, query, display: config };
// Update local state immediately
dispatch({ type: "UPDATE_WIDGET", payload: { id: widgetId, widget } });
// Queue sync to server (processed sequentially)
queueWidgetSync("update", {
widgetId,
title,
query,
config: JSON.stringify(config),
});
},
[queueWidgetSync]
);
const deleteWidget = useCallback(
(widgetId: string) => {
// Update local state immediately
dispatch({ type: "DELETE_WIDGET", payload: { id: widgetId } });
// Queue sync to server (processed sequentially)
queueWidgetSync("delete", { widgetId });
},
[queueWidgetSync]
);
const duplicateWidget = useCallback(
(widgetId: string) => {
// Guard: check widget limit (title widgets don't count)
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
onWidgetLimitReached?.();
return;
}
const newId = nanoid(8);
// Update local state immediately
dispatch({ type: "DUPLICATE_WIDGET", payload: { id: widgetId, newId } });
// Queue sync to server (processed sequentially)
// Send the client-generated newId so the server uses the same ID for the duplicate
queueWidgetSync("duplicate", { widgetId, newId });
},
[countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
);
const renameWidget = useCallback(
(widgetId: string, title: string) => {
// Update local state immediately
dispatch({ type: "RENAME_WIDGET", payload: { id: widgetId, title } });
// Queue sync to server (processed sequentially)
queueWidgetSync("rename", { widgetId, title });
},
[queueWidgetSync]
);
const updateLayout = useCallback(
(newLayout: LayoutItem[]) => {
// Skip if not yet initialized (prevents saving during mount/navigation)
if (!isInitializedRef.current) {
return;
}
const newLayoutJson = JSON.stringify(newLayout);
// Skip if layout hasn't actually changed
if (newLayoutJson === currentLayoutJsonRef.current) {
return;
}
// Update local state immediately
dispatch({ type: "UPDATE_LAYOUT", payload: { layout: newLayout } });
// Clear existing debounce timeout
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
}
// Debounce before queueing - this ensures rapid layout changes
// (like dragging) don't queue up many requests
layoutDebounceRef.current = setTimeout(() => {
currentLayoutJsonRef.current = newLayoutJson;
// Queue layout sync (replaces any pending layout sync in queue)
queueLayoutSync(newLayout);
}, 500);
},
[queueLayoutSync]
);
const openAddEditor = useCallback(() => {
dispatch({ type: "OPEN_ADD_EDITOR" });
}, []);
const openEditEditor = useCallback((widgetId: string, widget: WidgetData) => {
dispatch({ type: "OPEN_EDIT_EDITOR", payload: { widgetId, widget } });
}, []);
const closeEditor = useCallback(() => {
dispatch({ type: "CLOSE_EDITOR" });
}, []);
// -------------------------------------------------------------------------
// Return value
// -------------------------------------------------------------------------
return {
/** Current dashboard state */
state,
/** Action dispatchers */
actions: {
addWidget,
addTitleWidget,
updateWidget,
renameWidget,
deleteWidget,
duplicateWidget,
updateLayout,
openAddEditor,
openEditEditor,
closeEditor,
},
};
}
@@ -0,0 +1,35 @@
import { useEffect, useRef } from "react";
type UseElementVisibilityOptions = {
onVisibilityChange?: (isVisible: boolean) => void;
};
export function useElementVisibility({
onVisibilityChange,
}: UseElementVisibilityOptions = {}) {
const ref = useRef<HTMLDivElement>(null);
const isVisibleRef = useRef(false);
const callbackRef = useRef(onVisibilityChange);
callbackRef.current = onVisibilityChange;
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
const nowVisible = entry.isIntersecting;
if (isVisibleRef.current !== nowVisible) {
isVisibleRef.current = nowVisible;
callbackRef.current?.(nowVisible);
}
},
{ threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, isVisibleRef };
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
type UseIntervalOptions = {
/** If passed, will refresh every interval MS */
interval?: number;
onLoad?: boolean;
onFocus?: boolean;
disabled?: boolean;
callback: () => void;
};
export function useInterval({
interval,
onLoad = true,
onFocus = true,
disabled = false,
callback,
}: UseIntervalOptions) {
// Always keep the latest callback in a ref so the effects below
// never close over a stale version.
const latestCallback = useRef(callback);
useEffect(() => {
latestCallback.current = callback;
}, [callback]);
// On interval
useEffect(() => {
if (!interval || interval <= 0 || disabled) return;
const intervalId = setInterval(() => {
latestCallback.current();
}, interval);
return () => clearInterval(intervalId);
}, [interval, disabled]);
// On focus
useEffect(() => {
if (!onFocus || disabled) return;
const handleFocus = () => {
if (document.visibilityState === "visible") {
latestCallback.current();
}
};
// Revalidate when the page becomes visible
document.addEventListener("visibilitychange", handleFocus);
// Revalidate when the window gains focus
window.addEventListener("focus", handleFocus);
return () => {
document.removeEventListener("visibilitychange", handleFocus);
window.removeEventListener("focus", handleFocus);
};
}, [onFocus, disabled]);
// On load
useEffect(() => {
if (disabled || !onLoad) return;
latestCallback.current();
}, [disabled, onLoad]);
}
+26
View File
@@ -61,3 +61,29 @@ export function useIsImpersonating(matches?: UIMatch[]) {
});
return data?.isImpersonating === true;
}
export type CustomDashboard = UseDataFunctionReturn<typeof orgLoader>["customDashboards"][number];
export function useCustomDashboards(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.customDashboards ?? [];
}
export function useDashboardLimits(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.dashboardLimits ?? { used: 0, limit: 3 };
}
export function useWidgetLimitPerDashboard(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.widgetLimitPerDashboard ?? 16;
}
+16 -1
View File
@@ -1,6 +1,6 @@
import { useLocation } from "@remix-run/react";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useOrganizationChanged } from "./useOrganizations";
import { useOptionalUser, useUserChanged } from "./useUser";
import { useProjectChanged } from "./useProject";
@@ -68,3 +68,18 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
posthog.capture("$pageview");
}, [location, logging]);
};
export function usePostHogTracking() {
const capture = useCallback(
(eventName: string, properties?: Record<string, unknown>) => {
posthog.capture(eventName, properties);
},
[]
);
const startSessionRecording = useCallback(() => {
posthog.startSessionRecording();
}, []);
return { capture, startSessionRecording };
}
@@ -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",
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",
display: {
type: "bignumber",
column: "success_percentage",
aggregation: "avg",
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')",
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",
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;",
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;",
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",
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",
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: "avg",
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",
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",
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",
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: "avg",
seriesColors: {},
},
},
xyQl3FAd: {
title: "Queued",
query:
"SELECT\r\n count() AS queued\r\nFROM\r\n runs\r\nWHERE status IN ('Dequeued', 'Queued')",
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: {
@@ -81,12 +81,17 @@ export class LogDetailPresenter {
// Ignore parse errors
}
const durationMs = (typeof log.duration === "number" ? log.duration : Number(log.duration)) / 1_000_000;
return {
// Use :: separator to match LogsListPresenter format
id: `${log.trace_id}::${log.span_id}::${log.run_id}::${log.start_time}`,
runId: log.run_id,
taskIdentifier: log.task_identifier,
startTime: convertClickhouseDateTime64ToJsDate(log.start_time).toISOString(),
triggeredTimestamp: new Date(
convertClickhouseDateTime64ToJsDate(log.start_time).getTime() + durationMs
).toISOString(),
traceId: log.trace_id,
spanId: log.span_id,
parentSpanId: log.parent_span_id || null,
@@ -1,13 +1,11 @@
import { z } from "zod";
import { type ClickHouse } from "@internal/clickhouse";
import {
type PrismaClientOrTransaction,
} from "@trigger.dev/database";
import { type ClickHouse, type WhereCondition } from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
import parseDuration from "parse-duration";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { timeFilterFromTo, timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
@@ -28,14 +26,9 @@ type ErrorAttributes = {
};
function escapeClickHouseString(val: string): string {
return val
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_");
return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
export type LogsListOptions = {
userId?: string;
projectId: string;
@@ -50,7 +43,6 @@ export type LogsListOptions = {
retentionLimitDays?: number;
// search
search?: string;
includeDebugLogs?: boolean;
// pagination
direction?: Direction;
cursor?: string;
@@ -69,7 +61,6 @@ export const LogsListOptionsSchema = z.object({
defaultPeriod: z.string().optional(),
retentionLimitDays: z.number().int().positive().optional(),
search: z.string().max(1000).optional(),
includeDebugLogs: z.boolean().optional(),
direction: z.enum(["forward", "backward"]).optional(),
cursor: z.string().optional(),
pageSize: z.number().int().positive().max(1000).optional(),
@@ -83,14 +74,16 @@ export type LogsListAppliedFilters = LogsList["filters"];
// Cursor is a base64 encoded JSON of the pagination keys
type LogCursor = {
organizationId: string;
environmentId: string;
unixTimestamp: number;
triggeredTimestamp: string; // DateTime64(9) string
traceId: string;
};
const LogCursorSchema = z.object({
organizationId: z.string(),
environmentId: z.string(),
unixTimestamp: z.number(),
triggeredTimestamp: z.string(),
traceId: z.string(),
});
@@ -115,34 +108,19 @@ function decodeCursor(cursor: string): LogCursor | null {
// Convert display level to ClickHouse kinds and statuses
function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?: string[] } {
switch (level) {
case "TRACE":
return { kinds: ["SPAN"] };
case "DEBUG":
return { kinds: ["DEBUG_EVENT", "LOG_DEBUG"] };
return { kinds: ["LOG_DEBUG"] };
case "INFO":
return { kinds: ["LOG_INFO", "LOG_LOG"] };
case "WARN":
return { kinds: ["LOG_WARN"] };
case "ERROR":
return { kinds: ["LOG_ERROR"], statuses: ["ERROR"] };
return { kinds: ["LOG_ERROR", "SPAN_EVENT"], statuses: ["ERROR"] };
}
}
function convertDateToNanoseconds(date: Date): bigint {
return BigInt(date.getTime()) * 1_000_000n;
}
function formatNanosecondsForClickhouse(ns: bigint): string {
const nsString = ns.toString();
// Handle negative numbers (dates before 1970-01-01)
if (nsString.startsWith("-")) {
const absString = nsString.slice(1);
const padded = absString.padStart(19, "0");
return "-" + padded.slice(0, 10) + "." + padded.slice(10);
}
// Pad positive numbers to 19 digits to ensure correct slicing
const padded = nsString.padStart(19, "0");
return padded.slice(0, 10) + "." + padded.slice(10);
}
export class LogsListPresenter extends BasePresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
@@ -166,29 +144,20 @@ export class LogsListPresenter extends BasePresenter {
to,
cursor,
pageSize = DEFAULT_PAGE_SIZE,
includeDebugLogs = true,
defaultPeriod,
retentionLimitDays,
}: LogsListOptions
) {
const time = timeFilters({
const time = timeFilterFromTo({
period,
from,
to,
defaultPeriod,
defaultPeriod: defaultPeriod ?? "1h",
});
let effectiveFrom = time.from;
let effectiveTo = time.to;
if (!effectiveFrom && !effectiveTo && time.period) {
const periodMs = parseDuration(time.period);
if (periodMs) {
effectiveFrom = new Date(Date.now() - periodMs);
effectiveTo = new Date();
}
}
// Apply retention limit if provided
let wasClampedByRetention = false;
if (retentionLimitDays !== undefined && effectiveFrom) {
@@ -252,7 +221,12 @@ export class LogsListPresenter extends BasePresenter {
);
}
const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder();
const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder();
// This should be removed once we clear the old inserts, 30 DAYS, the materialized view excludes events without trace_id)
queryBuilder.where("trace_id != ''", {
environmentId,
});
queryBuilder.where("environment_id = {environmentId: String}", {
environmentId,
@@ -263,29 +237,17 @@ export class LogsListPresenter extends BasePresenter {
});
queryBuilder.where("project_id = {projectId: String}", { projectId });
if (effectiveFrom) {
const fromNs = convertDateToNanoseconds(effectiveFrom);
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
queryBuilder.where("start_time >= {fromTime: String}", {
fromTime: formatNanosecondsForClickhouse(fromNs),
queryBuilder.where("triggered_timestamp >= {triggeredAtStart: DateTime64(3)}", {
triggeredAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
}
if (effectiveTo) {
const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo;
const toNs = convertDateToNanoseconds(clampedTo);
queryBuilder.where("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
queryBuilder.where("start_time <= {toTime: String}", {
toTime: formatNanosecondsForClickhouse(toNs),
queryBuilder.where("triggered_timestamp <= {triggeredAtEnd: DateTime64(3)}", {
triggeredAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
}
@@ -307,81 +269,55 @@ export class LogsListPresenter extends BasePresenter {
queryBuilder.where(
"(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
{
searchPattern: `%${searchTerm}%`
searchPattern: `%${searchTerm}%`,
}
);
}
if (levels && levels.length > 0) {
const conditions: string[] = [];
const params: Record<string, string[]> = {};
const conditions: WhereCondition[] = [];
for (const level of levels) {
const filter = levelToKindsAndStatuses(level);
const levelConditions: string[] = [];
for (let i = 0; i < levels.length; i++) {
const filter = levelToKindsAndStatuses(levels[i]);
if (filter.kinds && filter.kinds.length > 0) {
const kindsKey = `kinds_${level}`;
let kindCondition = `kind IN {${kindsKey}: Array(String)}`;
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
levelConditions.push(kindCondition);
params[kindsKey] = filter.kinds;
conditions.push({
clause: `kind IN {kinds_${i}: Array(String)} AND status NOT IN {excluded_statuses: Array(String)}`,
params: {
[`kinds_${i}`]: filter.kinds,
excluded_statuses: ["ERROR", "CANCELLED"],
},
});
}
if (filter.statuses && filter.statuses.length > 0) {
const statusesKey = `statuses_${level}`;
levelConditions.push(`status IN {${statusesKey}: Array(String)}`);
params[statusesKey] = filter.statuses;
}
if (levelConditions.length > 0) {
conditions.push(`(${levelConditions.join(" OR ")})`);
conditions.push({
clause: `status IN {statuses_${i}: Array(String)}`,
params: { [`statuses_${i}`]: filter.statuses },
});
}
}
if (conditions.length > 0) {
queryBuilder.where(`(${conditions.join(" OR ")})`, params);
}
queryBuilder.whereOr(conditions);
}
// Debug logs are available only to admins
if (includeDebugLogs === false) {
queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", {
debugKinds: ["DEBUG_EVENT"],
});
queryBuilder.where("NOT ((kind = 'LOG_INFO') AND (attributes_text = '{}'))");
}
queryBuilder.where("kind NOT IN {debugSpans: Array(String)}", {
debugSpans: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"],
});
// kindCondition += ` `;
// params["excluded_statuses"] = ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"];
queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')");
// Cursor pagination
// Cursor-based pagination using lexicographic comparison on (triggered_timestamp, trace_id).
// Since ORDER BY is DESC, "next page" means rows that sort *after* the cursor, i.e. less-than.
// The OR handles the tiebreaker: rows with an earlier timestamp always qualify, and rows
// with the *same* timestamp only qualify if their trace_id is also smaller.
// Equivalent to: WHERE (triggered_timestamp, trace_id) < (cursor.triggered_timestamp, cursor.trace_id)
const decodedCursor = cursor ? decodeCursor(cursor) : null;
if (decodedCursor) {
queryBuilder.where(
"(environment_id, toUnixTimestamp(start_time), trace_id) < ({cursorEnvId: String}, {cursorUnixTimestamp: Int64}, {cursorTraceId: String})",
`(triggered_timestamp < {cursorTriggeredTimestamp: String} OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String}))`,
{
cursorEnvId: decodedCursor.environmentId,
cursorUnixTimestamp: decodedCursor.unixTimestamp,
cursorTriggeredTimestamp: decodedCursor.triggeredTimestamp,
cursorTraceId: decodedCursor.traceId,
}
);
}
queryBuilder.orderBy("environment_id DESC, toUnixTimestamp(start_time) DESC, trace_id DESC");
queryBuilder.orderBy("triggered_timestamp DESC, trace_id DESC");
// Limit + 1 to check if there are more results
queryBuilder.limit(pageSize + 1);
@@ -399,10 +335,10 @@ export class LogsListPresenter extends BasePresenter {
let nextCursor: string | undefined;
if (hasMore && logs.length > 0) {
const lastLog = logs[logs.length - 1];
const unixTimestamp = Math.floor(new Date(lastLog.start_time).getTime() / 1000);
nextCursor = encodeCursor({
organizationId,
environmentId,
unixTimestamp,
triggeredTimestamp: lastLog.triggered_timestamp,
traceId: lastLog.trace_id,
});
}
@@ -430,6 +366,9 @@ export class LogsListPresenter extends BasePresenter {
runId: log.run_id,
taskIdentifier: log.task_identifier,
startTime: convertClickhouseDateTime64ToJsDate(log.start_time).toISOString(),
triggeredTimestamp: convertClickhouseDateTime64ToJsDate(
log.triggered_timestamp
).toISOString(),
traceId: log.trace_id,
spanId: log.span_id,
parentSpanId: log.parent_span_id || null,
@@ -468,10 +407,13 @@ export class LogsListPresenter extends BasePresenter {
hasFilters,
hasAnyLogs: transformedLogs.length > 0,
searchTerm: search,
retention: retentionLimitDays !== undefined ? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
} : undefined,
retention:
retentionLimitDays !== undefined
? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
}
: undefined,
};
}
}
@@ -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`;
}
@@ -291,7 +291,7 @@ function Upgradable({
}: ConcurrencyResult) {
const lastSubmission = useActionData();
const [form, { environments: formEnvironments }] = useForm({
id: "purchase-concurrency",
id: "allocate-concurrency",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
@@ -446,7 +446,9 @@ function Upgradable({
</TableRow>
</TableBody>
</Table>
<FormError id={formEnvironments.id}>{formEnvironments.error}</FormError>
<FormError id={formEnvironments.id}>
{formEnvironments.error ?? formEnvironments.initialError?.[""]?.[0]}
</FormError>
</div>
<Form className="flex flex-col gap-2" method="post" {...form.props} id="allocate">
<input type="hidden" name="action" value="allocate" />
@@ -664,7 +666,9 @@ function PurchaseConcurrencyModal({
onChange={(e) => setAmountValue(Number(e.target.value))}
disabled={isLoading}
/>
<FormError id={amount.errorId}>{amount.error}</FormError>
<FormError id={amount.errorId}>
{amount.error ?? amount.initialError?.[""]?.[0]}
</FormError>
<FormError>{form.error}</FormError>
</InputGroup>
</Fieldset>
@@ -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>
@@ -16,7 +16,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { LogsListPresenter, LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import type { LogLevel } from "~/utils/logUtils";
import { $replica, prisma } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
@@ -36,12 +36,11 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Switch } from "~/components/primitives/Switch";
import { Button } from "~/components/primitives/Buttons";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
// Valid log levels for filtering
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
@@ -95,7 +94,6 @@ async function hasLogsPageAccess(
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const isAdmin = user.admin || user.isImpersonating;
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
@@ -126,7 +124,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const runId = url.searchParams.get("runId") ?? undefined;
const search = url.searchParams.get("search") ?? undefined;
const levels = parseLevelsFromUrl(url);
const showDebug = url.searchParams.get("showDebug") === "true";
const period = url.searchParams.get("period") ?? undefined;
const fromStr = url.searchParams.get("from");
const toStr = url.searchParams.get("to");
@@ -137,7 +134,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const presenter = new LogsListPresenter($replica, clickhouseClient);
const presenter = new LogsListPresenter($replica, logsClickhouseClient);
const listPromise = presenter
.call(project.organizationId, environment.id, {
@@ -150,7 +147,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
period,
from,
to,
includeDebugLogs: isAdmin && showDebug,
defaultPeriod: "1h",
retentionLimitDays
})
@@ -163,15 +159,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typeddefer({
data: listPromise,
isAdmin,
showDebug,
defaultPeriod: "1h",
retentionLimitDays,
});
};
export default function Page() {
const { data, isAdmin, showDebug, defaultPeriod, retentionLimitDays } =
const { data, defaultPeriod, retentionLimitDays } =
useTypedLoaderData<typeof loader>();
return (
@@ -199,8 +193,6 @@ export default function Page() {
errorElement={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
/>
@@ -218,8 +210,6 @@ export default function Page() {
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
/>
@@ -235,15 +225,11 @@ export default function Page() {
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
<FiltersBar
list={result}
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
/>
<LogsList
list={result}
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
/>
</div>
@@ -258,14 +244,10 @@ export default function Page() {
function FiltersBar({
list,
isAdmin,
showDebug,
defaultPeriod,
retentionLimitDays,
}: {
list?: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>;
isAdmin: boolean;
showDebug: boolean;
defaultPeriod?: string;
retentionLimitDays: number;
}) {
@@ -280,16 +262,6 @@ function FiltersBar({
searchParams.has("from") ||
searchParams.has("to");
const handleDebugToggle = useCallback((checked: boolean) => {
const url = new URL(window.location.href);
if (checked) {
url.searchParams.set("showDebug", "true");
} else {
url.searchParams.delete("showDebug");
}
window.location.href = url.toString();
}, []);
return (
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
<div className="flex flex-row flex-wrap items-center gap-1">
@@ -329,16 +301,6 @@ function FiltersBar({
</>
)}
</div>
<div className="flex items-center gap-2">
{isAdmin && (
<Switch
variant="small"
label="Debug"
checked={showDebug}
onCheckedChange={handleDebugToggle}
/>
)}
</div>
</div>
);
}
@@ -347,8 +309,6 @@ function LogsList({
list,
}: {
list: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>; //exclude error, it is handled
isAdmin: boolean;
showDebug: boolean;
defaultPeriod?: string;
}) {
const navigation = useNavigation();
@@ -362,7 +322,10 @@ function LogsList({
const [nextCursor, setNextCursor] = useState<string | undefined>(list.pagination.next);
// Selected log state - managed locally to avoid triggering navigation
const [selectedLogId, setSelectedLogId] = useState<string | undefined>();
const [selectedLogId, setSelectedLogId] = useState<string | undefined>(() => {
const params = new URLSearchParams(location.search);
return params.get("log") ?? undefined;
});
// Track which filter state (search params) the current fetcher request corresponds to
const fetcherFilterStateRef = useRef<string>(location.search);
@@ -373,8 +336,9 @@ function LogsList({
useEffect(() => {
setAccumulatedLogs([]);
setNextCursor(undefined);
// Close side panel when filters change to avoid showing a log that's no longer visible
setSelectedLogId(undefined);
// Preserve log selection from URL param, clear if not present
const params = new URLSearchParams(location.search);
setSelectedLogId(params.get("log") ?? undefined);
}, [location.search]);
// Populate accumulated logs when new data arrives
@@ -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 ?? defaultPeriod}
from={from ?? null}
to={to ?? null}
taskIdentifiers={tasks.length > 0 ? tasks : undefined}
queues={queues.length > 0 ? queues : undefined}
config={widget.display}
organizationId={organization.id}
projectId={project.id}
environmentId={environment.id}
refreshIntervalMs={60_000}
isResizing={resizingItemId === key}
isDraggable={editable}
onEdit={
onEditWidget
? (resultData) => onEditWidget(key, { ...widget, resultData })
: undefined
}
onRename={
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
}
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
onDuplicate={
onDuplicateWidget
? (resultData) => onDuplicateWidget(key, { ...widget, resultData })
: undefined
}
/>
)}
</div>
))}
</ReactGridLayout>
)}
</div>
</div>
);
}
function useContainerWidth(initialWidth = 1280) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(initialWidth);
const [mounted, setMounted] = useState(false);
const measureWidth = useCallback(() => {
if (containerRef.current) {
setWidth(containerRef.current.offsetWidth);
}
}, []);
useEffect(() => {
measureWidth();
setMounted(true);
const element = containerRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, [measureWidth]);
return { width, containerRef, mounted };
}
@@ -0,0 +1,772 @@
import { ArrowUpCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useNavigation } from "@remix-run/react";
import { IconChartHistogram, IconEdit, IconTypography } from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { toast } from "sonner";
import { z } from "zod";
import { defaultChartConfig } from "~/components/code/ChartConfigPanel";
import { Feedback } from "~/components/Feedback";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverVerticalEllipseTrigger,
} from "~/components/primitives/Popover";
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
import { ToastUI } from "~/components/primitives/Toast";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor";
import { $replica, prisma } from "~/db.server";
import { env } from "~/env.server";
import { useDashboardEditor } from "~/hooks/useDashboardEditor";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization, useWidgetLimitPerDashboard } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
queryPath,
v3BillingPath,
v3BuiltInDashboardPath,
} from "~/utils/pathBuilder";
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { Type } from "lucide-react";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const dashboardPresenter = new MetricDashboardPresenter();
const queryPresenter = new QueryPresenter();
const [dashboard, { defaultQuery, history }, possibleTasks] = await Promise.all([
dashboardPresenter.customDashboard({
friendlyId: dashboardId,
organizationId: project.organizationId,
}),
queryPresenter.call({
organizationId: project.organizationId,
}),
getAllTaskIdentifiers($replica, environment.id),
]);
// Admins and impersonating users can use EXPLAIN
const isAdmin = user.admin || user.isImpersonating;
// Compute widget count from dashboard layout
const widgetCount = Object.keys(dashboard.layout.widgets).length;
return typedjson({
...dashboard,
// Query editor data
queryDefaultQuery: defaultQuery,
queryHistory: history,
isAdmin,
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
possibleTasks: possibleTasks
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
.sort((a, b) => a.slug.localeCompare(b.slug)),
widgetCount,
});
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Load the dashboard
const dashboard = await prisma.metricsDashboard.findFirst({
where: {
friendlyId: dashboardId,
organizationId: project.organizationId,
},
});
if (!dashboard) {
throw new Response("Dashboard not found", { status: 404 });
}
const formData = await request.formData();
const action = formData.get("action");
switch (action) {
case "delete": {
await prisma.metricsDashboard.delete({
where: { id: dashboard.id },
});
return redirectWithSuccessMessage(
v3BuiltInDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
"overview"
),
request,
`Deleted "${dashboard.title}" dashboard`
);
}
case "rename": {
const newTitle = formData.get("title");
if (typeof newTitle !== "string" || newTitle.trim().length === 0) {
throw new Response("Title is required", { status: 400 });
}
await prisma.metricsDashboard.update({
where: { id: dashboard.id },
data: { title: newTitle.trim() },
});
return typedjson({ success: true });
}
default: {
throw new Response("Invalid action", { status: 400 });
}
}
};
export default function Page() {
const {
friendlyId,
title,
layout: dashboardLayout,
defaultPeriod,
queryDefaultQuery,
queryHistory,
isAdmin,
maxRows,
possibleTasks,
widgetCount: initialWidgetCount,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
// Widget limits
const widgetLimitPerDashboard = useWidgetLimitPerDashboard();
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricWidgetsPerDashboard;
const canExceedWidgets = typeof planLimits === "object" && planLimits.canExceed === true;
// Build the action URLs - both use the resource route to avoid full page renders on POST
const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`;
const layoutActionUrl = widgetActionUrl;
// Handle sync errors by showing a toast
const handleSyncError = useCallback((error: Error, action: string) => {
const actionMessages: Record<string, string> = {
add: "Failed to add widget",
update: "Failed to update widget",
delete: "Failed to delete widget",
duplicate: "Failed to duplicate widget",
layout: "Failed to save layout",
};
const message = actionMessages[action] || "Failed to save changes";
toast.custom((t) => (
<ToastUI
variant="error"
message={`${message}. Your changes may not be saved.`}
t={t as string}
title="Sync Error"
/>
));
}, []);
// Add title dialog state
const [showAddTitleDialog, setShowAddTitleDialog] = useState(false);
const [newTitleValue, setNewTitleValue] = useState("");
// Widget limit dialog state (triggered when hook blocks add/duplicate)
const [showWidgetLimitDialog, setShowWidgetLimitDialog] = useState(false);
const handleWidgetLimitReached = useCallback(() => {
setShowWidgetLimitDialog(true);
}, []);
// Use the dashboard editor hook for all state management
const { state, actions } = useDashboardEditor({
initialData: dashboardLayout,
widgetActionUrl,
layoutActionUrl,
widgetLimit: canExceedWidgets ? undefined : widgetLimitPerDashboard,
onSyncError: handleSyncError,
onWidgetLimitReached: handleWidgetLimitReached,
});
// Reactive widget count from editor state (title widgets don't count against limits)
const currentWidgetCount = Object.values(state.widgets).filter(
(w) => w.display.type !== "title"
).length;
const totalWidgetCount = Object.keys(state.widgets).length;
const widgetLimits = { used: currentWidgetCount, limit: widgetLimitPerDashboard };
const widgetIsAtLimit = currentWidgetCount >= widgetLimitPerDashboard;
const widgetLimitRatio =
widgetLimits.limit > 0 ? widgetLimits.used / widgetLimits.limit : widgetLimits.used > 0 ? 1 : 0;
const widgetLimitPercent = Math.min(100, Math.max(0, Math.round(widgetLimitRatio * 100)));
const widgetCanUpgrade = plan?.v3Subscription?.plan && !canExceedWidgets;
// Build the query action URL for the editor
const queryActionUrl = queryPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug }
);
// Handle save from the QueryEditor
const handleSave = useCallback(
(data: QueryEditorSaveData) => {
if (state.editorMode?.type === "add") {
actions.addWidget(data.title, data.query, data.config);
} else if (state.editorMode?.type === "edit") {
actions.updateWidget(state.editorMode.widgetId, data.title, data.query, data.config);
}
},
[state.editorMode, actions]
);
// Render save button for the QueryEditor
const renderSaveForm = useCallback(
(data: QueryEditorSaveData) => {
const isAdd = state.editorMode?.type === "add";
return (
<Button
type="button"
variant="primary/small"
disabled={!data.query}
onClick={() => handleSave(data)}
>
{isAdd ? "Add to dashboard" : "Save changes"}
</Button>
);
},
[state.editorMode, handleSave]
);
// Prepare editor props when in editor mode
const editorProps = state.editorMode
? (() => {
const mode =
state.editorMode.type === "add"
? { type: "dashboard-add" as const, dashboardId: friendlyId, dashboardName: title }
: {
type: "dashboard-edit" as const,
dashboardId: friendlyId,
dashboardName: title,
widgetId: state.editorMode.widgetId,
widgetName: state.editorMode.widget.title,
};
// For edit mode, use the widget's existing values as defaults
const editorDefaultQuery =
state.editorMode.type === "edit" ? state.editorMode.widget.query : queryDefaultQuery;
const editorDefaultChartConfig =
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "chart"
? {
chartType: state.editorMode.widget.display.chartType,
xAxisColumn: state.editorMode.widget.display.xAxisColumn,
yAxisColumns: state.editorMode.widget.display.yAxisColumns,
groupByColumn: state.editorMode.widget.display.groupByColumn,
stacked: state.editorMode.widget.display.stacked,
sortByColumn: state.editorMode.widget.display.sortByColumn,
sortDirection: state.editorMode.widget.display.sortDirection,
aggregation: state.editorMode.widget.display.aggregation,
seriesColors: state.editorMode.widget.display.seriesColors,
}
: defaultChartConfig;
const editorDefaultBigNumberConfig =
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "bignumber"
? {
column: state.editorMode.widget.display.column,
aggregation: state.editorMode.widget.display.aggregation,
sortDirection: state.editorMode.widget.display.sortDirection,
abbreviate: state.editorMode.widget.display.abbreviate,
prefix: state.editorMode.widget.display.prefix,
suffix: state.editorMode.widget.display.suffix,
}
: undefined;
const editorDefaultResultsView =
state.editorMode.type === "edit" ? state.editorMode.widget.display.type : "table";
// Pass the existing result data when editing
const editorDefaultData =
state.editorMode.type === "edit" ? state.editorMode.widget.resultData : undefined;
return {
mode,
editorDefaultQuery,
editorDefaultChartConfig,
editorDefaultBigNumberConfig,
editorDefaultResultsView,
editorDefaultData,
};
})()
: null;
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
<PageAccessories>
{totalWidgetCount > 0 &&
(widgetIsAtLimit ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon}>
Add chart
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this
dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
) : (
<>
<Button
variant="primary/small"
LeadingIcon={IconChartHistogram}
className="pl-1.5"
iconSpacing="gap-x-1.5"
onClick={actions.openAddEditor}
>
Add chart
</Button>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
))}
<Popover>
<PopoverVerticalEllipseTrigger variant="secondary" />
<PopoverContent className="w-fit min-w-[10rem] p-1" align="end">
<div className="flex flex-col gap-1">
<RenameDashboardDialog title={title} />
<DeleteDashboardDialog title={title} />
</div>
</PopoverContent>
</Popover>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1">
{totalWidgetCount === 0 ? (
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={IconChartHistogram}
iconClassName="text-metrics"
panelClassName="max-full"
title="Add your first chart"
accessory={
<Button
variant="primary/small"
LeadingIcon={PlusIcon}
onClick={actions.openAddEditor}
>
Add chart
</Button>
}
>
<Paragraph variant="small">
Charts let you visualize your task metrics. Write a query to pull data from your
runs, then choose how to display it on this dashboard.
</Paragraph>
</InfoPanel>
</MainCenteredContainer>
) : (
<MetricDashboard
key={friendlyId}
layout={state.layout}
widgets={state.widgets}
defaultPeriod={defaultPeriod}
editable={true}
possibleTasks={possibleTasks}
onLayoutChange={actions.updateLayout}
onEditWidget={actions.openEditEditor}
onRenameWidget={actions.renameWidget}
onDeleteWidget={actions.deleteWidget}
onDuplicateWidget={actions.duplicateWidget}
/>
)}
</div>
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
widgetIsAtLimit ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${widgetLimitRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${widgetLimitPercent}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{widgetIsAtLimit ? (
<Header3 className="text-error">
You've used all {widgetLimits.limit} of your available widgets. Upgrade your
plan to enable more.
</Header3>
) : (
<Header3>
You've used {widgetLimits.used}/{widgetLimits.limit} of your charts
</Header3>
)}
{widgetCanUpgrade ? (
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Request more</Button>}
defaultValue="help"
/>
)}
</div>
</div>
</div>
</div>
</PageBody>
{/* Query Editor Sheet - opens on top of the dashboard */}
<Sheet open={!!state.editorMode} onOpenChange={(open) => !open && actions.closeEditor()}>
<SheetContent
side="right"
className="w-[90vw] max-w-none rounded-l-lg border-l border-grid-dimmed p-0 sm:max-w-none"
>
{editorProps && (
<QueryEditor
defaultQuery={editorProps.editorDefaultQuery}
defaultScope="environment"
defaultPeriod={defaultPeriod}
defaultResultsView={
editorProps.editorDefaultResultsView === "chart"
? "graph"
: editorProps.editorDefaultResultsView === "bignumber"
? "bignumber"
: "table"
}
defaultChartConfig={editorProps.editorDefaultChartConfig}
defaultBigNumberConfig={editorProps.editorDefaultBigNumberConfig}
defaultData={editorProps.editorDefaultData}
history={queryHistory}
isAdmin={isAdmin}
maxRows={maxRows}
queryActionUrl={queryActionUrl}
mode={editorProps.mode}
maxPeriodDays={maxPeriodDays}
save={renderSaveForm}
onClose={actions.closeEditor}
/>
)}
</SheetContent>
</Sheet>
{/* Add title dialog */}
<Dialog open={showAddTitleDialog} onOpenChange={setShowAddTitleDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Add title</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (newTitleValue.trim()) {
actions.addTitleWidget(newTitleValue.trim());
setShowAddTitleDialog(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={newTitleValue}
onChange={(e) => setNewTitleValue(e.target.value)}
placeholder="Section title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!newTitleValue.trim()}>
Add
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Widget limit dialog - triggered by hook when add/duplicate is blocked */}
<Dialog open={showWidgetLimitDialog} onOpenChange={setShowWidgetLimitDialog}>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</PageContainer>
);
}
function RenameDashboardDialog({ title }: { title: string }) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const [newTitle, setNewTitle] = useState(title);
const isRenaming =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "rename";
// Close dialog when navigation completes
useEffect(() => {
if (navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
// Sync newTitle state when title changes (after successful rename)
useEffect(() => {
setNewTitle(title);
}, [title]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={IconEdit}
fullWidth
textAlignLeft
className="pl-0.5 pr-3"
leadingIconClassName="gap-x-0"
>
Rename dashboard
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Rename dashboard</DialogHeader>
<Form method="post" className="space-y-4">
<input type="hidden" name="action" value="rename" />
<InputGroup>
<Label>Title</Label>
<Input
name="title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Dashboard title"
required
autoFocus
/>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
disabled={isRenaming || !newTitle.trim()}
>
{isRenaming ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
</Dialog>
);
}
function DeleteDashboardDialog({ title }: { title: string }) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const isDeleting =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
// Close dialog when navigation completes
useEffect(() => {
if (navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
fullWidth
textAlignLeft
>
Delete dashboard
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>Delete dashboard</DialogHeader>
<div className="mb-2 mt-4 flex flex-col gap-2">
<Paragraph>
Are you sure you want to delete <strong>"{title}"</strong>? This action cannot be undone
and all widgets on this dashboard will be permanently removed.
</Paragraph>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<Form method="post">
<Button
type="submit"
name="action"
value="delete"
variant="danger/medium"
LeadingIcon={TrashIcon}
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,8 @@
import { useState } from "react";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { AIQueryInput } from "~/components/code/AIQueryInput";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import type { AITimeFilter } from "./types";
export function AITabContent({
@@ -41,8 +43,8 @@ export function AITabContent({
/>
<div className="pt-4">
<Header3 className="mb-2 text-text-bright">Example prompts</Header3>
<div className="space-y-2">
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
<div className="flex flex-wrap gap-2">
{examplePrompts.map((example) => (
<button
key={example}
@@ -53,9 +55,12 @@ export function AITabContent({
key: (prev?.key ?? 0) + 1,
}));
}}
className="block w-full rounded-md border border-grid-dimmed bg-charcoal-800 px-3 py-2 text-left text-sm text-text-dimmed transition-colors hover:border-grid-bright hover:bg-charcoal-750 hover:text-text-bright"
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500 focus-custom focus-visible:!rounded-full"
>
{example}
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph variant="small" className="text-left transition group-hover:text-text-bright">
{example}
</Paragraph>
</button>
))}
</div>
@@ -63,4 +68,3 @@ export function AITabContent({
</div>
);
}
@@ -38,6 +38,19 @@ ORDER BY p50_duration_ms DESC
LIMIT 20`,
scope: "environment",
},
{
title: "Runs over time",
description:
"Count of runs bucketed over time. The bucket size adjusts automatically to the time range.",
query: `SELECT
timeBucket(),
count() AS run_count
FROM runs
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000`,
scope: "environment",
},
{
title: "Most expensive 100 runs (past 7d)",
description: "Top 100 runs by cost over the last 7 days.",
@@ -1,9 +1,12 @@
import { useState } from "react";
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
import { Button } from "~/components/primitives/Buttons";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { Popover, PopoverTrigger } from "~/components/primitives/Popover";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
import { timeFilterRenderValues } from "~/components/runs/v3/SharedFilters";
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
const SQL_KEYWORDS = [
"SELECT",
@@ -91,23 +94,32 @@ export function QueryHistoryPopover({
<PopoverTrigger asChild>
<Button
type="button"
variant="tertiary/small"
variant="secondary/small"
LeadingIcon={ClockRotateLeftIcon}
leadingIconClassName="-mr-1.5"
TrailingIcon={ChevronUpDownIcon}
disabled={history.length === 0}
>
History
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[400px] min-w-0 overflow-hidden p-0"
<PopoverPrimitive.Content
className={cn(
"z-50 w-[400px] min-w-0 overflow-hidden rounded border border-charcoal-700 bg-background-bright p-0 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
align="start"
sideOffset={6}
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
>
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="max-h-[40rem] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="p-1">
{history.map((item) => {
// Format time filter display
const { valueLabel } = timeFilterRenderValues({ period: item.filterPeriod ?? undefined, from: item.filterFrom ?? undefined, to: item.filterTo ?? undefined });
const { valueLabel } = timeFilterRenderValues({
period: item.filterPeriod ?? undefined,
from: item.filterFrom ?? undefined,
to: item.filterTo ?? undefined,
});
return (
<button
@@ -117,21 +129,16 @@ export function QueryHistoryPopover({
onQuerySelected(item);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900"
className="flex w-full flex-col gap-1 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-750"
>
<div className="flex flex-1 flex-col items-start gap-0.5 overflow-hidden">
<div className="flex w-full flex-col items-start">
{item.title ? (
<>
<p className="w-full truncate text-left text-sm font-medium text-text-bright">
{item.title}
</p>
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
{highlightSQL(item.query)}
</p>
</>
<p className="mb-1 truncate text-left text-sm font-medium text-text-bright">
{item.title}
</p>
) : (
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-[#9b99ff]">
{highlightSQL(item.query)}
<p className="mb-1 truncate text-left font-mono text-xs text-text-bright">
{item.query.split("\n")[0].slice(0, 60)}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
@@ -140,13 +147,17 @@ export function QueryHistoryPopover({
{item.userName && <span>· {item.userName}</span>}
</div>
</div>
<div className="w-full border-l-2 border-charcoal-600 pl-2.5">
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
{highlightSQL(item.query)}
</p>
</div>
</button>
);
})}
</div>
</div>
</PopoverContent>
</PopoverPrimitive.Content>
</Popover>
);
}
@@ -488,6 +488,11 @@ ORDER BY run_count DESC`,
<FunctionCategory
title="Date/time functions"
functions={[
{
name: "timeBucket()",
desc: "Auto-bucket by time period. Uses the table's time column with an interval based on the query's time range.",
example: "SELECT timeBucket(), count() FROM runs GROUP BY timeBucket",
},
{ name: "now()", desc: "Current date and time", example: "now()" },
{ name: "today()", desc: "Current date", example: "today()" },
{ name: "yesterday()", desc: "Yesterday's date", example: "yesterday()" },
@@ -54,7 +54,7 @@ import {
TestTaskPresenter,
} from "~/presenters/v3/TestTaskPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
import { TestTaskService } from "~/v3/services/testTask.server";
@@ -75,14 +75,15 @@ import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server";
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, taskParam } = v3TaskParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
@@ -90,7 +91,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
@@ -100,14 +101,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new TestTaskPresenter($replica, clickhouseClient);
try {
const result = await presenter.call({
userId,
projectId: project.id,
taskIdentifier: taskParam,
environment: environment,
});
const [result, regionsResult] = await Promise.all([
presenter.call({
userId: user.id,
projectId: project.id,
taskIdentifier: taskParam,
environment: environment,
}),
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
}),
]);
return typedjson(result);
return typedjson({ ...result, regions: regionsResult.regions });
} catch (error) {
return redirectWithErrorMessage(
v3TestPath({ slug: organizationSlug }, { slug: projectParam }, environment),
@@ -118,15 +126,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = v3TaskParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
return redirectBackWithErrorMessage(request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
return redirectBackWithErrorMessage(request, "Environment not found");
@@ -290,6 +298,7 @@ export default function Page() {
templates={result.taskRunTemplates}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
regions={result.regions}
/>
);
}
@@ -304,6 +313,7 @@ export default function Page() {
possibleTimezones={result.possibleTimezones}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
regions={result.regions}
/>
);
}
@@ -324,6 +334,7 @@ function StandardTaskForm({
templates,
disableVersionSelection,
allowArbitraryQueues,
regions,
}: {
task: StandardTaskResult["task"];
queues: Required<StandardTaskResult>["queue"][];
@@ -332,6 +343,7 @@ function StandardTaskForm({
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
regions: Region[];
}) {
const environment = useEnvironment();
const { value, replace } = useSearchParams();
@@ -373,6 +385,12 @@ function StandardTaskForm({
);
const [queueValue, setQueueValue] = useState<string | undefined>(lastRun?.queue);
const [machineValue, setMachineValue] = useState<string | undefined>(lastRun?.machinePreset);
const isDev = environment.type === "DEVELOPMENT";
const defaultRegion = regions.find((r) => r.isDefault);
const [regionValue, setRegionValue] = useState<string | undefined>(
isDev ? undefined : defaultRegion?.name
);
const [maxAttemptsValue, setMaxAttemptsValue] = useState<number | undefined>(
lastRun?.maxAttempts
);
@@ -381,6 +399,12 @@ function StandardTaskForm({
);
const [tagsValue, setTagsValue] = useState<string[]>(lastRun?.runTags ?? []);
const regionItems = regions.map((r) => ({
value: r.name,
label: r.description ? `${r.name}${r.description}` : r.name,
isDefault: r.isDefault,
}));
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
@@ -409,6 +433,7 @@ function StandardTaskForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -580,6 +605,45 @@ function StandardTaskForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
key={`region-${environment.id}`}
{...conform.select(region)}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={isDev ? undefined : (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -803,6 +867,7 @@ function ScheduledTaskForm({
templates,
disableVersionSelection,
allowArbitraryQueues,
regions,
}: {
task: ScheduledTaskResult["task"];
runs: ScheduledRun[];
@@ -812,6 +877,7 @@ function ScheduledTaskForm({
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
regions: Region[];
}) {
const environment = useEnvironment();
@@ -833,6 +899,12 @@ function ScheduledTaskForm({
);
const [queueValue, setQueueValue] = useState<string | undefined>(lastRun?.queue);
const [machineValue, setMachineValue] = useState<string | undefined>(lastRun?.machinePreset);
const isDev = environment.type === "DEVELOPMENT";
const defaultRegion = regions.find((r) => r.isDefault);
const [regionValue, setRegionValue] = useState<string | undefined>(
isDev ? undefined : defaultRegion?.name
);
const [maxAttemptsValue, setMaxAttemptsValue] = useState<number | undefined>(
lastRun?.maxAttempts
);
@@ -843,6 +915,12 @@ function ScheduledTaskForm({
const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false);
const regionItems = regions.map((r) => ({
value: r.name,
label: r.description ? `${r.name}${r.description}` : r.name,
isDefault: r.isDefault,
}));
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
@@ -879,6 +957,7 @@ function ScheduledTaskForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -1101,6 +1180,45 @@ function ScheduledTaskForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
key={`region-${environment.id}`}
{...conform.select(region)}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={isDev ? undefined : (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -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,
});
};
@@ -48,6 +48,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
contentHash: body.data.contentHash,
git: body.data.gitMeta,
runtime: body.data.runtime,
buildServerMetadata: body.data.buildServerMetadata,
})
.match(
() => {
+77
View File
@@ -0,0 +1,77 @@
import { json } from "@remix-run/server-runtime";
import { QueryError } from "@internal/clickhouse";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { executeQuery, type QueryScope } from "~/services/queryService.server";
import { logger } from "~/services/logger.server";
import { rowsToCSV } from "~/utils/dataExport";
const BodySchema = z.object({
query: z.string(),
scope: z.enum(["organization", "project", "environment"]).default("environment"),
period: z.string().nullish(),
from: z.string().nullish(),
to: z.string().nullish(),
format: z.enum(["json", "csv"]).default("json"),
});
const { action, loader } = createActionApiRoute(
{
body: BodySchema,
corsStrategy: "all",
},
async ({ body, authentication }) => {
const { query, scope, period, from, to, format } = body;
const env = authentication.environment;
const queryResult = await executeQuery({
name: "api-query",
query,
scope: scope as QueryScope,
organizationId: env.organization.id,
projectId: env.project.id,
environmentId: env.id,
period,
from,
to,
history: {
source: "API",
},
});
if (!queryResult.success) {
const message =
queryResult.error instanceof QueryError
? queryResult.error.message
: "An unexpected error occurred while executing the query.";
logger.error("Query API error", {
error: queryResult.error,
query,
});
return json(
{ error: message },
{ status: queryResult.error instanceof QueryError ? 400 : 500 }
);
}
const { result, periodClipped, maxQueryPeriod } = queryResult;
if (format === "csv") {
const csv = rowsToCSV(result.rows, result.columns);
return json({
format: "csv",
results: csv,
});
}
return json({
format: "json",
results: result.rows,
});
}
);
export { action, loader };
@@ -128,7 +128,7 @@ export default function LoginPage() {
<div className="relative w-full">
{data.lastAuthMethod === "github" && <LastUsedBadge />}
<Form
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
action={`/auth/github${data.redirectTo ? `?redirectTo=${encodeURIComponent(data.redirectTo)}` : ""}`}
method="post"
className="w-full"
>
@@ -148,7 +148,7 @@ export default function LoginPage() {
<div className="relative w-full">
{data.lastAuthMethod === "google" && <LastUsedBadge />}
<Form
action={`/auth/google${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
action={`/auth/google${data.redirectTo ? `?redirectTo=${encodeURIComponent(data.redirectTo)}` : ""}`}
method="post"
className="w-full"
>
+284
View File
@@ -0,0 +1,284 @@
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";
import { env } from "~/env.server";
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: env.METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT,
});
if (!queryResult.success) {
return json(
{
success: false as const,
error: queryResult.error.message,
},
{ status: 400 }
);
}
return json({
success: true as const,
data: {
rows: queryResult.result.rows,
columns: queryResult.result.columns,
stats: queryResult.result.stats,
hiddenColumns: queryResult.result.hiddenColumns ?? null,
reachedMaxRows: queryResult.result.reachedMaxRows,
periodClipped: queryResult.periodClipped,
maxQueryPeriod: queryResult.maxQueryPeriod,
timeRange: {
from: queryResult.timeRange.from.toISOString(),
to: queryResult.timeRange.to.toISOString(),
},
},
});
};
type MetricWidgetProps = {
/** Unique key for this widget - used to identify the fetcher */
widgetKey: string;
title: string;
config: QueryWidgetConfig;
refreshIntervalMs?: number;
isResizing?: boolean;
isDraggable?: boolean;
/** Callback when edit button is clicked - receives current data */
onEdit?: (data: QueryWidgetData) => void;
/** Callback when rename is clicked - receives new title */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked */
onDelete?: () => void;
/** Callback when duplicate is clicked - receives current data */
onDuplicate?: (data: QueryWidgetData) => void;
} & z.infer<typeof MetricWidgetQuery>;
export function MetricWidget({
widgetKey,
title,
config,
refreshIntervalMs,
isResizing,
isDraggable,
onEdit,
onRename,
onDelete,
onDuplicate,
...props
}: MetricWidgetProps) {
const [response, setResponse] = useState<MetricWidgetActionResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
// Track the latest props so the submit callback always uses fresh values
// without needing to be recreated (which would cause useInterval to re-register listeners).
const propsRef = useRef(props);
propsRef.current = props;
const submit = useCallback(() => {
// Skip fetching if the widget is not visible on screen
if (!isVisibleRef.current) return;
// Abort any in-flight request for this widget
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsLoading(true);
fetch(`/resources/metric`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(propsRef.current),
signal: controller.signal,
})
.then(async (res) => {
try {
return (await res.json()) as MetricWidgetActionResponse;
} catch {
throw new Error(`Request failed (${res.status})`);
}
})
.then((data) => {
if (!controller.signal.aborted) {
setResponse(data);
setIsLoading(false);
}
})
.catch((err) => {
if (err instanceof DOMException && err.name === "AbortError") return;
if (!controller.signal.aborted) {
// Only surface the error if there's no existing successful data to preserve
setResponse((prev) =>
prev?.success ? prev : { success: false, error: err.message || "Network error" }
);
setIsLoading(false);
}
});
}, []);
// Track visibility so we only fetch for on-screen widgets.
// When a widget scrolls into view and has no data yet, trigger a load.
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
onVisibilityChange: (visible) => {
if (visible && !response) {
submit();
}
},
});
// Clean up on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
// Reload periodically and on focus (onLoad: false — the useEffect below handles initial load)
useInterval({ interval: refreshIntervalMs, callback: submit, onLoad: false });
// Reload on mount and when query, time period, or filters change
useEffect(() => {
submit();
}, [
submit,
props.query,
props.from,
props.to,
props.period,
props.scope,
JSON.stringify(props.taskIdentifiers),
JSON.stringify(props.queues),
]);
const data = response?.success
? { rows: response.data.rows, columns: response.data.columns }
: { rows: [], columns: [] };
const timeRange = response?.success ? response.data.timeRange : undefined;
return (
<div ref={visibilityRef} className="h-full">
<QueryWidget
title={title}
titleString={title}
query={props.query}
config={config}
isLoading={isLoading}
data={data}
timeRange={timeRange}
error={response?.success === false ? response.error : undefined}
isResizing={isResizing}
isDraggable={isDraggable}
onEdit={onEdit}
onRename={onRename}
onDelete={onDelete}
onDuplicate={onDuplicate}
/>
</div>
);
}
@@ -0,0 +1,492 @@
import { type ActionFunctionArgs } from "@remix-run/node";
import { nanoid } from "nanoid";
import { z } from "zod";
import { typedjson } from "remix-typedjson";
import { prisma } from "~/db.server";
import { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
DashboardLayout,
LayoutItem,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
// Schemas for each action type
const AddWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required").nullish(),
title: z.string().min(1, "Title is required"),
query: z.string().default(""),
config: z.string().transform((str, ctx) => {
try {
const parsed = JSON.parse(str);
const result = QueryWidgetConfig.safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid widget config",
});
return z.NEVER;
}
return result.data;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON",
});
return z.NEVER;
}
}),
});
const UpdateWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required"),
title: z.string().min(1, "Title is required"),
query: z.string().min(1, "Query is required"),
config: z.string().transform((str, ctx) => {
try {
const parsed = JSON.parse(str);
const result = QueryWidgetConfig.safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid widget config",
});
return z.NEVER;
}
return result.data;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON",
});
return z.NEVER;
}
}),
});
const RenameWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required"),
title: z.string().min(1, "Title is required"),
});
const DeleteWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required"),
});
const DuplicateWidgetSchema = z.object({
widgetId: z.string().min(1, "Widget ID is required"),
newId: z.string().min(1, "New widget ID is required").nullish(),
});
const SaveLayoutSchema = z.object({
layout: z.string().transform((str, ctx) => {
try {
const parsed = JSON.parse(str);
const result = z.array(LayoutItem).safeParse(parsed);
if (!result.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid layout format",
});
return z.NEVER;
}
return result.data;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid JSON",
});
return z.NEVER;
}
}),
});
const ParamsSchema = EnvironmentParamSchema.extend({
dashboardId: z.string(),
});
// Check widget limit for add/duplicate actions (title widgets don't count)
async function checkWidgetLimit(
existingLayout: z.infer<typeof DashboardLayout>,
organizationId: string
) {
const currentWidgetCount = Object.values(existingLayout.widgets).filter(
(w) => w.display.type !== "title"
).length;
const plan = await getCurrentPlan(organizationId);
const metricWidgetsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
?.metricWidgetsPerDashboard;
const widgetLimit =
typeof metricWidgetsLimitValue === "number"
? metricWidgetsLimitValue
: (metricWidgetsLimitValue?.number ?? 16);
if (currentWidgetCount >= widgetLimit) {
throw new Response("Widget limit reached", { status: 403 });
}
}
// Optimistic concurrency save: uses updateMany so we can include updatedAt
// in the where clause. If another request modified the dashboard between our
// read and this write, updatedAt won't match and count will be 0.
async function saveDashboardLayout(
dashboardId: string,
expectedUpdatedAt: Date,
updatedLayout: z.infer<typeof DashboardLayout>
) {
const result = await prisma.metricsDashboard.updateMany({
where: { id: dashboardId, updatedAt: expectedUpdatedAt },
data: {
layout: JSON.stringify(updatedLayout),
},
});
if (result.count === 0) {
throw new Response(
"Dashboard was modified by another request. Please refresh and try again.",
{ status: 409 }
);
}
}
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, dashboardId } = ParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
// Load the dashboard
const dashboard = await prisma.metricsDashboard.findFirst({
where: {
friendlyId: dashboardId,
organizationId: project.organizationId,
},
});
if (!dashboard) {
throw new Response("Dashboard not found", { status: 404 });
}
const formData = await request.formData();
const action = formData.get("action");
// Parse existing layout (shared across all actions)
let existingLayout: z.infer<typeof DashboardLayout>;
try {
const parsed = JSON.parse(dashboard.layout);
const layoutResult = DashboardLayout.safeParse(parsed);
if (!layoutResult.success) {
// For add action, we can start with empty layout
if (action === "add") {
existingLayout = {
version: "1",
layout: [],
widgets: {},
};
} else {
throw new Response("Invalid dashboard layout", { status: 500 });
}
} else {
existingLayout = layoutResult.data;
}
} catch (e) {
if (e instanceof Response) throw e;
if (action === "add") {
existingLayout = {
version: "1",
layout: [],
widgets: {},
};
} else {
throw new Response("Failed to parse dashboard layout", { status: 500 });
}
}
switch (action) {
case "add": {
const rawData = {
widgetId: formData.get("widgetId"),
title: formData.get("title"),
query: formData.get("query"),
config: formData.get("config"),
};
const result = AddWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { title, query, config } = result.data;
// Validate that non-title widgets have a query
if (config.type !== "title" && !query) {
throw new Response("Query is required for chart widgets", { status: 400 });
}
// Title widgets don't count against the limit
if (config.type !== "title") {
await checkWidgetLimit(existingLayout, project.organizationId);
}
// Use client-provided widget ID if available, otherwise generate one
// Using the client's ID ensures optimistic UI state stays in sync with the server
const widgetId = result.data.widgetId || nanoid(8);
// Calculate position at the bottom
let maxBottom = 0;
for (const item of existingLayout.layout) {
const itemBottom = item.y + item.h;
if (itemBottom > maxBottom) {
maxBottom = itemBottom;
}
}
// Add new layout item (full width, height depends on widget type)
const newLayoutItem = {
i: widgetId,
x: 0,
y: maxBottom,
w: 12,
h: config.type === "title" ? 2 : 15,
};
// Add new widget
const newWidget = {
title,
query,
display: config,
};
// Update the layout
const updatedLayout = {
...existingLayout,
layout: [...existingLayout.layout, newLayoutItem],
widgets: {
...existingLayout.widgets,
[widgetId]: newWidget,
},
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, widgetId });
}
case "update": {
const rawData = {
widgetId: formData.get("widgetId"),
title: formData.get("title"),
query: formData.get("query"),
config: formData.get("config"),
};
const result = UpdateWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { widgetId, title, query, config } = result.data;
// Check if widget exists
if (!existingLayout.widgets[widgetId]) {
throw new Response("Widget not found", { status: 404 });
}
// Update the widget
const updatedWidget = {
title,
query,
display: config,
};
// Update the layout
const updatedLayout = {
...existingLayout,
widgets: {
...existingLayout.widgets,
[widgetId]: updatedWidget,
},
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, updatedTitle: title });
}
case "rename": {
const rawData = {
widgetId: formData.get("widgetId"),
title: formData.get("title"),
};
const result = RenameWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { widgetId, title } = result.data;
// Check if widget exists
if (!existingLayout.widgets[widgetId]) {
throw new Response("Widget not found", { status: 404 });
}
// Update just the title
const updatedLayout = {
...existingLayout,
widgets: {
...existingLayout.widgets,
[widgetId]: {
...existingLayout.widgets[widgetId],
title,
},
},
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, renamedTitle: title });
}
case "delete": {
const rawData = {
widgetId: formData.get("widgetId"),
};
const result = DeleteWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { widgetId } = result.data;
// Get widget title before deleting (for the success message)
const widget = existingLayout.widgets[widgetId];
const widgetTitle = widget?.title ?? "Widget";
// Remove widget from layout and widgets
const updatedLayout = {
...existingLayout,
layout: existingLayout.layout.filter((item) => item.i !== widgetId),
widgets: Object.fromEntries(
Object.entries(existingLayout.widgets).filter(([key]) => key !== widgetId)
),
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, deletedTitle: widgetTitle });
}
case "duplicate": {
await checkWidgetLimit(existingLayout, project.organizationId);
const rawData = {
widgetId: formData.get("widgetId"),
newId: formData.get("newId"),
};
const result = DuplicateWidgetSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
const { widgetId } = result.data;
// Find the original widget
const originalWidget = existingLayout.widgets[widgetId];
if (!originalWidget) {
throw new Response("Widget not found", { status: 404 });
}
// Find the original layout item
const originalLayoutItem = existingLayout.layout.find((item) => item.i === widgetId);
if (!originalLayoutItem) {
throw new Response("Widget layout not found", { status: 404 });
}
// Use client-provided ID if available, otherwise generate one
// Using the client's ID ensures optimistic UI state stays in sync with the server
const newWidgetId = result.data.newId || nanoid(8);
// Calculate position at the bottom
let maxBottom = 0;
for (const item of existingLayout.layout) {
const itemBottom = item.y + item.h;
if (itemBottom > maxBottom) {
maxBottom = itemBottom;
}
}
// Create new layout item with same dimensions but at the bottom
const newLayoutItem = {
i: newWidgetId,
x: 0,
y: maxBottom,
w: originalLayoutItem.w,
h: originalLayoutItem.h,
};
// Create new widget with "(Copy)" suffix
const newWidget = {
...originalWidget,
title: `${originalWidget.title} (Copy)`,
};
// Update the layout
const updatedLayout = {
...existingLayout,
layout: [...existingLayout.layout, newLayoutItem],
widgets: {
...existingLayout.widgets,
[newWidgetId]: newWidget,
},
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true, duplicatedTitle: originalWidget.title });
}
case "layout": {
const result = SaveLayoutSchema.safeParse({
layout: formData.get("layout"),
});
if (!result.success) {
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
}
// Update layout positions while preserving widgets
const updatedLayout = {
...existingLayout,
layout: result.data.layout,
};
// Save to database (with optimistic concurrency check)
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
return typedjson({ success: true });
}
default: {
throw new Response("Invalid action", { status: 400 });
}
}
};
@@ -0,0 +1,84 @@
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { findProjectBySlug } from "~/models/project.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
const CreateDashboardSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional().default(""),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Check dashboard limit
const [plan, existingCount] = await Promise.all([
getCurrentPlan(project.organizationId),
prisma.metricsDashboard.count({
where: { organizationId: project.organizationId },
}),
]);
const metricDashboardsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
?.metricDashboards;
const dashboardLimit =
typeof metricDashboardsLimitValue === "number"
? metricDashboardsLimitValue
: (metricDashboardsLimitValue?.number ?? 3);
if (existingCount >= dashboardLimit) {
throw new Response("Dashboard limit reached", { status: 403 });
}
const formData = await request.formData();
const rawData = {
title: formData.get("title"),
description: formData.get("description") ?? "",
};
const result = CreateDashboardSchema.safeParse(rawData);
if (!result.success) {
throw new Response("Invalid form data", { status: 400 });
}
const { title, description } = result.data;
// Create empty default layout
const defaultLayout = JSON.stringify({
version: "1",
layout: [],
widgets: {},
});
const dashboard = await prisma.metricsDashboard.create({
data: {
friendlyId: generateFriendlyId("dashboard"),
title,
description,
organizationId: project.organizationId,
projectId: project.id,
ownerId: userId,
layout: defaultLayout,
},
});
// Redirect to the new dashboard
return redirect(
v3CustomDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: dashboard.friendlyId }
)
);
};
@@ -1,202 +0,0 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/node";
import { z } from "zod";
import { MachinePresetName } from "@trigger.dev/core/v3";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { $replica } from "~/db.server";
import type { TaskRunStatus } from "@trigger.dev/database";
// Valid TaskRunStatus values
const VALID_TASK_RUN_STATUSES = [
"PENDING",
"QUEUED",
"EXECUTING",
"WAITING_FOR_EXECUTION",
"WAITING",
"COMPLETED_SUCCESSFULLY",
"COMPLETED_WITH_ERRORS",
"SYSTEM_FAILURE",
"FAILURE",
"CANCELED",
] as const;
// Schema for validating run context data
export const RunContextSchema = z.object({
id: z.string(),
friendlyId: z.string(),
taskIdentifier: z.string(),
status: z.enum(VALID_TASK_RUN_STATUSES),
createdAt: z.string().datetime(),
startedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().optional(),
isTest: z.boolean(),
tags: z.array(z.string()),
queue: z.string(),
concurrencyKey: z.string().nullable(),
usageDurationMs: z.number(),
costInCents: z.number(),
baseCostInCents: z.number(),
machinePreset: MachinePresetName.nullable(),
version: z.string().optional(),
rootRun: z
.object({
friendlyId: z.string(),
taskIdentifier: z.string(),
})
.nullable(),
parentRun: z
.object({
friendlyId: z.string(),
taskIdentifier: z.string(),
})
.nullable(),
batch: z
.object({
friendlyId: z.string(),
})
.nullable(),
schedule: z
.object({
friendlyId: z.string(),
})
.nullable(),
});
export type RunContext = z.infer<typeof RunContextSchema>;
// Fetch run context for a log entry
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, logId } = {
...EnvironmentParamSchema.parse(params),
logId: params.logId,
};
if (!logId) {
throw new Response("Log ID is required", { status: 400 });
}
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 });
}
// Parse the logId to extract runId
// Log ID format: traceId::spanId::runId::startTime (base64 encoded or plain)
const url = new URL(request.url);
const runId = url.searchParams.get("runId");
if (!runId) {
throw new Response("Run ID is required", { status: 400 });
}
// Fetch run details from Postgres
const run = await $replica.taskRun.findFirst({
select: {
id: true,
friendlyId: true,
taskIdentifier: true,
status: true,
createdAt: true,
startedAt: true,
completedAt: true,
isTest: true,
runTags: true,
queue: true,
concurrencyKey: true,
usageDurationMs: true,
costInCents: true,
baseCostInCents: true,
machinePreset: true,
scheduleId: true,
lockedToVersion: {
select: {
version: true,
},
},
rootTaskRun: {
select: {
friendlyId: true,
taskIdentifier: true,
},
},
parentTaskRun: {
select: {
friendlyId: true,
taskIdentifier: true,
},
},
batch: {
select: {
friendlyId: true,
},
},
},
where: {
friendlyId: runId,
runtimeEnvironmentId: environment.id,
},
});
if (!run) {
return json({ run: null });
}
// Fetch schedule if scheduleId exists
let schedule: { friendlyId: string } | null = null;
if (run.scheduleId) {
const scheduleData = await $replica.taskSchedule.findFirst({
select: { friendlyId: true },
where: { id: run.scheduleId },
});
schedule = scheduleData;
}
const runData = {
id: run.id,
friendlyId: run.friendlyId,
taskIdentifier: run.taskIdentifier,
status: run.status,
createdAt: run.createdAt.toISOString(),
startedAt: run.startedAt?.toISOString(),
completedAt: run.completedAt?.toISOString(),
isTest: run.isTest,
tags: run.runTags,
queue: run.queue,
concurrencyKey: run.concurrencyKey,
usageDurationMs: run.usageDurationMs,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
machinePreset: run.machinePreset,
version: run.lockedToVersion?.version,
rootRun: run.rootTaskRun
? {
friendlyId: run.rootTaskRun.friendlyId,
taskIdentifier: run.rootTaskRun.taskIdentifier,
}
: null,
parentRun: run.parentTaskRun
? {
friendlyId: run.parentTaskRun.friendlyId,
taskIdentifier: run.parentTaskRun.taskIdentifier,
}
: null,
batch: run.batch ? { friendlyId: run.batch.friendlyId } : null,
schedule: schedule,
};
// Validate the run data
const validatedRun = RunContextSchema.parse(runData);
return json({
run: validatedRun,
});
};
@@ -1,100 +0,0 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
// Convert ClickHouse kind to display level
function kindToLevel(
kind: string
): "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG" {
switch (kind) {
case "DEBUG_EVENT":
case "LOG_DEBUG":
return "DEBUG";
case "LOG_INFO":
return "INFO";
case "LOG_WARN":
return "WARN";
case "LOG_ERROR":
return "ERROR";
case "LOG_LOG":
return "LOG";
case "SPAN":
case "ANCESTOR_OVERRIDE":
case "SPAN_EVENT":
default:
return "TRACE";
}
}
// Fetch related spans for a log entry from the same trace
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, logId } = {
...EnvironmentParamSchema.parse(params),
logId: params.logId,
};
if (!logId) {
throw new Response("Log ID is required", { status: 400 });
}
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 });
}
// Get trace ID and run ID from query params
const url = new URL(request.url);
const traceId = url.searchParams.get("traceId");
const runId = url.searchParams.get("runId");
const currentSpanId = url.searchParams.get("spanId");
if (!traceId || !runId) {
throw new Response("Trace ID and Run ID are required", { status: 400 });
}
// Query ClickHouse for related spans in the same trace
const queryBuilder = clickhouseClient.taskEventsV2.logsListQueryBuilder();
queryBuilder.where("environment_id = {environmentId: String}", {
environmentId: environment.id,
});
queryBuilder.where("trace_id = {traceId: String}", { traceId });
queryBuilder.where("run_id = {runId: String}", { runId });
// Order by start time to show spans in chronological order
queryBuilder.orderBy("start_time ASC");
queryBuilder.limit(50);
const [queryError, records] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
const results = records || [];
const spans = results.map((row) => ({
id: `${row.trace_id}::${row.span_id}::${row.run_id}::${row.start_time}`,
spanId: row.span_id,
parentSpanId: row.parent_span_id || null,
message: row.message.substring(0, 200), // Truncate for list view
kind: row.kind,
level: kindToLevel(row.kind),
status: row.status,
startTime: new Date(Number(row.start_time) / 1_000_000).toISOString(),
duration: Number(row.duration),
isCurrent: row.span_id === currentSpanId,
}));
return json({ spans });
};
@@ -1,13 +1,14 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { requireUserId } from "~/services/session.server";
import { LogDetailPresenter } from "~/presenters/v3/LogDetailPresenter.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { $replica } from "~/db.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import type { TaskRunStatus } from "@trigger.dev/database";
const LogIdParamsSchema = z.object({
organizationSlug: z.string(),
@@ -42,7 +43,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const [traceId, spanId, , startTime] = parts;
const presenter = new LogDetailPresenter($replica, clickhouseClient);
const presenter = new LogDetailPresenter($replica, logsClickhouseClient);
let result;
try {
@@ -65,5 +66,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Log not found", { status: 404 });
}
return typedjson(result);
// Look up the run status from Postgres
let runStatus: TaskRunStatus | undefined;
if (result.runId) {
const run = await $replica.taskRun.findFirst({
select: { status: true },
where: {
friendlyId: result.runId,
runtimeEnvironmentId: environment.id,
},
});
runStatus = run?.status;
}
return typedjson({ ...result, runStatus });
};

Some files were not shown because too many files have changed in this diff Show More