Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98bf706437 | |||
| 354363e408 | |||
| 525145def0 | |||
| 23c327ea16 | |||
| d794101c67 | |||
| f325638892 | |||
| 68e3f8c9db | |||
| 2071090042 | |||
| 469b039090 | |||
| 38981f5e70 | |||
| 22505e9803 | |||
| 84a00b6eb6 | |||
| 506b161751 | |||
| 1ec7722690 | |||
| 2825a200e6 | |||
| eb0f963393 | |||
| 59b6eb9a3e | |||
| b793f33e59 | |||
| 6e0b54b081 | |||
| 4c986ad1eb | |||
| 7af789bc3e | |||
| 72ce6a8300 | |||
| 921285ca32 | |||
| 667a93ca3c | |||
| 4dfa65809a | |||
| d4cd34094e | |||
| 796ad29386 | |||
| b4e08bddec | |||
| 5d6085dcff | |||
| 1d744fa3c6 | |||
| a3d3b17df4 | |||
| 35e11e09a3 | |||
| 9030e94362 | |||
| 2462c80c8a | |||
| a8f9a90280 | |||
| 4b7f67604a | |||
| bfe417f679 | |||
| ba320a4bdb | |||
| bc0d1ff59a | |||
| 062bcaece8 | |||
| c2085e6cc6 |
@@ -1,16 +0,0 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Add `maxDelay` option to debounce feature. This allows setting a maximum time limit for how long a debounced run can be delayed, ensuring execution happens within a specified window even with continuous triggers.
|
||||
|
||||
```typescript
|
||||
await myTask.trigger(payload, {
|
||||
debounce: {
|
||||
key: "my-key",
|
||||
delay: "5s",
|
||||
maxDelay: "30m", // Execute within 30 minutes regardless of continuous triggers
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@trigger.dev/react-hooks": patch
|
||||
---
|
||||
|
||||
Fix `onComplete` callback firing prematurely when the realtime stream disconnects before the run finishes.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Aligned the SDK's `getRunIdForOptions` logic with the Core package to handle semantic targets (`root`, `parent`) in root tasks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Export `AnyOnStartAttemptHookFunction` type to allow defining `onStartAttempt` hooks for individual tasks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix runner getting stuck indefinitely when `execute()` is called on a dead child process.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
fix: vendor superjson to fix ESM/CJS compatibility
|
||||
|
||||
Bundle superjson during build to avoid `ERR_REQUIRE_ESM` errors on Node.js versions that don't support `require(ESM)` by default (< 22.12.0) and AWS Lambda which intentionally disables it.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add Vercel integration support to API schemas: `commitSHA` and `integrationDeployments` on deployment responses, and `source` field for environment variable imports.
|
||||
+4
-1
@@ -10,4 +10,7 @@ mpcgrid
|
||||
myftija
|
||||
nicktrn
|
||||
samejr
|
||||
isshaddad
|
||||
isshaddad
|
||||
# Outside contributors
|
||||
gautamsi
|
||||
capaj
|
||||
Vendored
+9
@@ -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",
|
||||
|
||||
Vendored
-1
@@ -7,6 +7,5 @@
|
||||
"packages/cli-v3/e2e": true
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.experimental.useTsgo": true,
|
||||
"chat.agent.maxRequests": 10000
|
||||
}
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,65 +1,42 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { BarChart3, LineChart } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { createValueFormatter } from "~/utils/columnFormat";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
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";
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
|
||||
import { aggregateValues } from "../primitives/charts/aggregation";
|
||||
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { getSeriesColor } from "./chartColors";
|
||||
|
||||
// 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];
|
||||
}
|
||||
const MAX_SERIES = 50;
|
||||
const MAX_SVG_ELEMENT_BUDGET = 6_000;
|
||||
const MIN_DATA_POINTS = 100;
|
||||
const MAX_DATA_POINTS = 500;
|
||||
|
||||
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 {
|
||||
data: Record<string, unknown>[];
|
||||
series: string[];
|
||||
/** Total number of series before any truncation (equals series.length when no truncation) */
|
||||
totalSeriesCount: number;
|
||||
/** Raw date values for determining formatting granularity */
|
||||
dateValues: Date[];
|
||||
/** Whether the x-axis is date-based (continuous time scale) */
|
||||
@@ -153,12 +130,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 +182,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 +206,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 +256,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 +366,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 +449,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;
|
||||
|
||||
@@ -471,6 +458,7 @@ function transformDataForChart(
|
||||
return {
|
||||
data: [],
|
||||
series: [],
|
||||
totalSeriesCount: 0,
|
||||
dateValues: [],
|
||||
isDateBased: false,
|
||||
xDataKey: xAxisColumn || "",
|
||||
@@ -489,24 +477,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)
|
||||
@@ -561,30 +562,57 @@ function transformDataForChart(
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
const seriesForBudget = Math.min(yAxisColumns.length, MAX_SERIES);
|
||||
const effectiveMaxPoints = Math.max(
|
||||
MIN_DATA_POINTS,
|
||||
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / seriesForBudget))
|
||||
);
|
||||
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
const rangeMs = rawMaxTime - rawMinTime;
|
||||
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
|
||||
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
|
||||
aggregation,
|
||||
effectiveMaxPoints
|
||||
);
|
||||
} else if (data.length > effectiveMaxPoints) {
|
||||
data = data.slice(0, effectiveMaxPoints);
|
||||
}
|
||||
|
||||
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
return {
|
||||
data,
|
||||
series: yAxisColumns,
|
||||
totalSeriesCount: yAxisColumns.length,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
};
|
||||
}
|
||||
|
||||
// With grouping: pivot data so each group value becomes a series
|
||||
const yCol = yAxisColumns[0]; // Use first Y column when grouping
|
||||
const groupValues = new Set<string>();
|
||||
|
||||
// For date-based, key by timestamp; otherwise by formatted string
|
||||
// Collect all values for aggregation
|
||||
// First pass: collect all values grouped by (xKey, groupValue) and accumulate
|
||||
// per-group totals so we can pick the top-N groups before building heavy data
|
||||
// objects with thousands of keys.
|
||||
const groupTotals = new Map<string, number>();
|
||||
const groupedByX = new Map<
|
||||
string | number,
|
||||
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
|
||||
@@ -593,29 +621,39 @@ function transformDataForChart(
|
||||
for (const row of rows) {
|
||||
const rawDate = tryParseDate(row[xAxisColumn]);
|
||||
|
||||
// Skip rows with invalid dates for date-based axes
|
||||
if (isDateBased && !rawDate) continue;
|
||||
|
||||
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
|
||||
const groupValue = String(row[groupByColumn] ?? "Unknown");
|
||||
const yValue = toNumber(row[yCol]);
|
||||
|
||||
groupValues.add(groupValue);
|
||||
groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue));
|
||||
|
||||
if (!groupedByX.has(xKey)) {
|
||||
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
|
||||
}
|
||||
|
||||
const existing = groupedByX.get(xKey)!;
|
||||
// Collect values for aggregation
|
||||
if (!existing.values[groupValue]) {
|
||||
existing.values[groupValue] = [];
|
||||
}
|
||||
existing.values[groupValue].push(yValue);
|
||||
}
|
||||
|
||||
// Convert to array format with aggregation applied
|
||||
const series = Array.from(groupValues).sort();
|
||||
// Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing
|
||||
// downstream (data objects, gap filling, legend totals, SVG rendering).
|
||||
const totalSeriesCount = groupTotals.size;
|
||||
let series: string[];
|
||||
if (groupTotals.size <= MAX_SERIES) {
|
||||
series = Array.from(groupTotals.keys()).sort();
|
||||
} else {
|
||||
series = Array.from(groupTotals.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, MAX_SERIES)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
}
|
||||
// Convert to array format with aggregation applied (only for kept series)
|
||||
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: xKey,
|
||||
@@ -629,23 +667,44 @@ function transformDataForChart(
|
||||
return point;
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
// Dynamic data-point budget based on the (already capped) series count
|
||||
const effectiveMaxPoints = Math.max(
|
||||
MIN_DATA_POINTS,
|
||||
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / series.length))
|
||||
);
|
||||
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
const rangeMs = rawMaxTime - rawMinTime;
|
||||
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
|
||||
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
|
||||
aggregation,
|
||||
effectiveMaxPoints
|
||||
);
|
||||
} else if (data.length > effectiveMaxPoints) {
|
||||
data = data.slice(0, effectiveMaxPoints);
|
||||
}
|
||||
|
||||
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
return {
|
||||
data,
|
||||
series,
|
||||
totalSeriesCount,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
@@ -657,25 +716,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 +765,10 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
rows,
|
||||
columns,
|
||||
config,
|
||||
timeRange,
|
||||
fullLegend = false,
|
||||
onViewAllLegendItems,
|
||||
isLoading = false,
|
||||
legendScrollable = false,
|
||||
}: QueryResultsChartProps) {
|
||||
const {
|
||||
@@ -743,12 +785,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
const {
|
||||
data: unsortedData,
|
||||
series,
|
||||
totalSeriesCount,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
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 +802,54 @@ 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]);
|
||||
|
||||
// Limit SVG-rendered series to MAX_SERIES (top N by total value)
|
||||
const visibleSeries = useMemo(
|
||||
() => (sortedSeries.length > MAX_SERIES ? sortedSeries.slice(0, MAX_SERIES) : sortedSeries),
|
||||
[sortedSeries]
|
||||
);
|
||||
|
||||
// X-axis tick formatter for date-based axes
|
||||
const seriesLimitCallout =
|
||||
totalSeriesCount > series.length ? (
|
||||
<div className="mt-1 px-2">
|
||||
<Callout variant="warning">
|
||||
{`Limited to the top ${
|
||||
series.length
|
||||
} of ${totalSeriesCount.toLocaleString()} series for performance reasons.`}
|
||||
</Callout>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// 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) => {
|
||||
@@ -774,20 +858,44 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
};
|
||||
}, [isDateBased, timeGranularity]);
|
||||
|
||||
// Create dynamic Y-axis formatter based on data range
|
||||
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
|
||||
// Resolve the Y-axis column format for formatting
|
||||
const yAxisFormat = useMemo(() => {
|
||||
if (yAxisColumns.length === 0) return undefined;
|
||||
const col = columns.find((c) => c.name === yAxisColumns[0]);
|
||||
return (col?.format ?? col?.customRenderType) as ColumnFormatType | undefined;
|
||||
}, [yAxisColumns, columns]);
|
||||
|
||||
// Create dynamic Y-axis formatter based on data range and format
|
||||
const yAxisFormatter = useMemo(
|
||||
() => createYAxisFormatter(data, series, yAxisFormat),
|
||||
[data, series, yAxisFormat]
|
||||
);
|
||||
|
||||
// Create value formatter for tooltips and legend based on column format
|
||||
const tooltipValueFormatter = useMemo(
|
||||
() => createValueFormatter(yAxisFormat),
|
||||
[yAxisFormat]
|
||||
);
|
||||
|
||||
// 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 +939,125 @@ 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 +1067,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 +1086,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
domain: yAxisDomain,
|
||||
};
|
||||
|
||||
const showLegend = series.length > 0;
|
||||
const showLegend = sortedSeries.length > 0;
|
||||
|
||||
if (chartType === "bar") {
|
||||
return (
|
||||
@@ -891,20 +1094,26 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={series}
|
||||
series={sortedSeries}
|
||||
visibleSeries={visibleSeries}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
legendValueFormatter={tooltipValueFormatter}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
state={isLoading ? "loading" : "loaded"}
|
||||
beforeLegend={seriesLimitCallout}
|
||||
>
|
||||
<Chart.Bar
|
||||
xAxisProps={xAxisPropsForBar}
|
||||
yAxisProps={yAxisProps}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
tooltipValueFormatter={tooltipValueFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
@@ -916,20 +1125,26 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={series}
|
||||
series={sortedSeries}
|
||||
visibleSeries={visibleSeries}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
legendValueFormatter={tooltipValueFormatter}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
state={isLoading ? "loading" : "loaded"}
|
||||
beforeLegend={seriesLimitCallout}
|
||||
>
|
||||
<Chart.Line
|
||||
xAxisProps={xAxisPropsForLine}
|
||||
yAxisProps={yAxisProps}
|
||||
stacked={stacked && series.length > 1}
|
||||
stacked={stacked && visibleSeries.length > 1}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
tooltipValueFormatter={tooltipValueFormatter}
|
||||
lineType="linear"
|
||||
/>
|
||||
</Chart.Root>
|
||||
@@ -937,9 +1152,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a Y-axis value formatter based on the data range
|
||||
* Creates a Y-axis value formatter based on the data range and optional format hint
|
||||
*/
|
||||
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
|
||||
function createYAxisFormatter(
|
||||
data: Record<string, unknown>[],
|
||||
series: string[],
|
||||
format?: ColumnFormatType
|
||||
) {
|
||||
// Find min and max values across all series
|
||||
let minVal = Infinity;
|
||||
let maxVal = -Infinity;
|
||||
@@ -956,6 +1175,46 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
|
||||
|
||||
const range = maxVal - minVal;
|
||||
|
||||
// Format-aware formatters
|
||||
if (format === "bytes" || format === "decimalBytes") {
|
||||
const divisor = format === "bytes" ? 1024 : 1000;
|
||||
const units =
|
||||
format === "bytes"
|
||||
? ["B", "KiB", "MiB", "GiB", "TiB"]
|
||||
: ["B", "KB", "MB", "GB", "TB"];
|
||||
return (value: number): string => {
|
||||
if (value === 0) return "0 B";
|
||||
// Use consistent unit for all ticks based on max value
|
||||
const i = Math.min(
|
||||
Math.max(0, Math.floor(Math.log(Math.abs(maxVal || 1)) / Math.log(divisor))),
|
||||
units.length - 1
|
||||
);
|
||||
const scaled = value / Math.pow(divisor, i);
|
||||
return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${units[i]}`;
|
||||
};
|
||||
}
|
||||
|
||||
if (format === "percent") {
|
||||
return (value: number): string => `${value.toFixed(range < 1 ? 2 : 1)}%`;
|
||||
}
|
||||
|
||||
if (format === "duration") {
|
||||
return (value: number): string => formatDurationMilliseconds(value, { style: "short" });
|
||||
}
|
||||
|
||||
if (format === "durationSeconds") {
|
||||
return (value: number): string =>
|
||||
formatDurationMilliseconds(value * 1000, { style: "short" });
|
||||
}
|
||||
|
||||
if (format === "costInDollars" || format === "cost") {
|
||||
return (value: number): string => {
|
||||
const dollars = format === "cost" ? value / 100 : value;
|
||||
return formatCurrencyAccurate(dollars);
|
||||
};
|
||||
}
|
||||
|
||||
// Default formatter
|
||||
return (value: number): string => {
|
||||
// Use abbreviations for large numbers
|
||||
if (Math.abs(value) >= 1_000_000) {
|
||||
@@ -988,13 +1247,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
|
||||
return Math.round(value).toString();
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -33,18 +35,15 @@ import { useCopy } from "~/hooks/useCopy";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatBytes, formatDecimalBytes, formatQuantity } from "~/utils/columnFormat";
|
||||
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 +53,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
|
||||
@@ -68,9 +67,10 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
|
||||
if (value === null) return "NULL";
|
||||
if (value === undefined) return "";
|
||||
|
||||
// Handle custom render types
|
||||
if (column.customRenderType) {
|
||||
switch (column.customRenderType) {
|
||||
// Handle format hints (from prettyFormat() or auto-populated from customRenderType)
|
||||
const formatType = column.format ?? column.customRenderType;
|
||||
if (formatType) {
|
||||
switch (formatType) {
|
||||
case "duration":
|
||||
if (typeof value === "number") {
|
||||
return formatDurationMilliseconds(value, { style: "short" });
|
||||
@@ -97,6 +97,26 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
|
||||
return value;
|
||||
}
|
||||
break;
|
||||
case "bytes":
|
||||
if (typeof value === "number") {
|
||||
return formatBytes(value);
|
||||
}
|
||||
break;
|
||||
case "decimalBytes":
|
||||
if (typeof value === "number") {
|
||||
return formatDecimalBytes(value);
|
||||
}
|
||||
break;
|
||||
case "percent":
|
||||
if (typeof value === "number") {
|
||||
return `${value.toFixed(2)}%`;
|
||||
}
|
||||
break;
|
||||
case "quantity":
|
||||
if (typeof value === "number") {
|
||||
return formatQuantity(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +180,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
|
||||
@@ -224,6 +244,21 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
|
||||
if (value === null) return 4; // "NULL"
|
||||
if (value === undefined) return 9; // "UNDEFINED"
|
||||
|
||||
// Handle format hint types - estimate their rendered width
|
||||
const fmt = column.format;
|
||||
if (fmt === "bytes" || fmt === "decimalBytes") {
|
||||
// e.g., "1.50 GiB" or "256.00 MB"
|
||||
return 12;
|
||||
}
|
||||
if (fmt === "percent") {
|
||||
// e.g., "45.23%"
|
||||
return 8;
|
||||
}
|
||||
if (fmt === "quantity") {
|
||||
// e.g., "1.50M"
|
||||
return 8;
|
||||
}
|
||||
|
||||
// Handle custom render types - estimate their rendered width
|
||||
if (column.customRenderType) {
|
||||
switch (column.customRenderType) {
|
||||
@@ -265,6 +300,8 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
|
||||
return typeof value === "string" ? Math.min(value.length, 20) : 12;
|
||||
case "queue":
|
||||
return typeof value === "string" ? Math.min(value.length, 25) : 15;
|
||||
case "deploymentId":
|
||||
return typeof value === "string" ? Math.min(value.length, 25) : 20;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +433,10 @@ function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const fmt = column.format;
|
||||
if (fmt === "bytes" || fmt === "decimalBytes" || fmt === "percent" || fmt === "quantity") {
|
||||
return true;
|
||||
}
|
||||
return isNumericType(column.type);
|
||||
}
|
||||
|
||||
@@ -416,7 +457,7 @@ function CellValueWrapper({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex-1"
|
||||
className="flex flex-1 items-center"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
@@ -462,6 +503,7 @@ function CellValue({
|
||||
</pre>
|
||||
}
|
||||
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -477,12 +519,45 @@ function CellValue({
|
||||
return <pre className="text-text-dimmed">UNDEFINED</pre>;
|
||||
}
|
||||
|
||||
// Check format hint for new format types (from prettyFormat())
|
||||
if (column.format && !column.customRenderType) {
|
||||
switch (column.format) {
|
||||
case "bytes":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{formatBytes(value)}</span>;
|
||||
}
|
||||
break;
|
||||
case "decimalBytes":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{formatDecimalBytes(value)}</span>;
|
||||
}
|
||||
break;
|
||||
case "percent":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{value.toFixed(2)}%</span>;
|
||||
}
|
||||
break;
|
||||
case "quantity":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{formatQuantity(value)}</span>;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// First check customRenderType for special rendering
|
||||
if (column.customRenderType) {
|
||||
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 +565,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;
|
||||
}
|
||||
@@ -573,6 +646,19 @@ function CellValue({
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
case "deploymentId": {
|
||||
if (typeof value === "string" && value.startsWith("deployment_")) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content="Jump to deployment"
|
||||
disableHoverableContent
|
||||
hidden={!hovered}
|
||||
button={<TextLink to={`/deployments/${value}`}>{value}</TextLink>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +693,7 @@ function CellValue({
|
||||
{truncateString(arrayString)}
|
||||
</span>
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -642,6 +729,7 @@ function CellValue({
|
||||
</pre>
|
||||
}
|
||||
button={<span>{truncateString(stringValue)}</span>}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -688,6 +776,7 @@ function JSONCellValue({ value }: { value: unknown }) {
|
||||
button={
|
||||
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -713,15 +802,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 +871,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 +895,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 +910,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 +930,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 +967,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 +987,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 +1005,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 +1065,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 +1076,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 +1097,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 +1131,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 +1178,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 +1210,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 +1218,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: 0–360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
|
||||
* s: 0–100 (saturation — 0 is gray, 100 is full color)
|
||||
* l: 0–100 (lightness — 0 is black, 50 is pure color, 100 is white)
|
||||
*/
|
||||
|
||||
interface HSLColor {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
interface ChartColorDef {
|
||||
name: string;
|
||||
hsl: HSLColor;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Palette — 30 distinct colors for chart series, defined in HSL
|
||||
// ---------------------------------------------------------------------------
|
||||
const CHART_COLOR_DEFS: ChartColorDef[] = [
|
||||
// Primary colors (high contrast, spread across hue wheel)
|
||||
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
|
||||
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
|
||||
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
|
||||
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
|
||||
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
|
||||
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
|
||||
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
|
||||
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
|
||||
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
|
||||
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
|
||||
// Extended palette
|
||||
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
|
||||
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
|
||||
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
|
||||
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
|
||||
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
|
||||
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
|
||||
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
|
||||
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
|
||||
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
|
||||
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
|
||||
// Additional distinct colors (lighter variants)
|
||||
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
|
||||
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
|
||||
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
|
||||
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
|
||||
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
|
||||
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
|
||||
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
|
||||
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
|
||||
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
|
||||
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HSL ↔ Hex conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert an HSL color (h: 0–360, s: 0–100, l: 0–100) to a hex string */
|
||||
function hslToHex({ h, s, l }: HSLColor): string {
|
||||
const sNorm = s / 100;
|
||||
const lNorm = l / 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
||||
const hPrime = h / 60;
|
||||
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
||||
const m = lNorm - c / 2;
|
||||
|
||||
let r1: number, g1: number, b1: number;
|
||||
|
||||
if (hPrime < 1) {
|
||||
r1 = c;
|
||||
g1 = x;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 2) {
|
||||
r1 = x;
|
||||
g1 = c;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 3) {
|
||||
r1 = 0;
|
||||
g1 = c;
|
||||
b1 = x;
|
||||
} else if (hPrime < 4) {
|
||||
r1 = 0;
|
||||
g1 = x;
|
||||
b1 = c;
|
||||
} else if (hPrime < 5) {
|
||||
r1 = x;
|
||||
g1 = 0;
|
||||
b1 = c;
|
||||
} else {
|
||||
r1 = c;
|
||||
g1 = 0;
|
||||
b1 = x;
|
||||
}
|
||||
|
||||
const toHex = (v: number) =>
|
||||
Math.round((v + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
|
||||
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
|
||||
}
|
||||
|
||||
/** Convert a hex string to HSL (h: 0–360, s: 0–100, l: 0–100) */
|
||||
function hexToHsl(hex: string): HSLColor {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (delta === 0) {
|
||||
return { h: 0, s: 0, l: Math.round(l * 100) };
|
||||
}
|
||||
|
||||
const s = delta / (1 - Math.abs(2 * l - 1));
|
||||
|
||||
let h: number;
|
||||
if (max === r) {
|
||||
h = 60 * (((g - b) / delta + 6) % 6);
|
||||
} else if (max === g) {
|
||||
h = 60 * ((b - r) / delta + 2);
|
||||
} else {
|
||||
h = 60 * ((r - g) / delta + 4);
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived hex palette (for consumers that need plain hex strings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
|
||||
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
|
||||
|
||||
/** Get the hex color for a series by its index (wraps around) */
|
||||
export function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hue-sorted palette (rainbow order for color pickers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SATURATION_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Chart colors sorted by perceived hue — the natural rainbow order
|
||||
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
|
||||
*
|
||||
* Very desaturated colors (like grays) are placed at the end since they don't
|
||||
* have a strong hue.
|
||||
*/
|
||||
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
|
||||
.sort((a, b) => {
|
||||
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
|
||||
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
|
||||
|
||||
// Push desaturated colors to the end
|
||||
if (aIsGray && !bIsGray) return 1;
|
||||
if (!aIsGray && bIsGray) return -1;
|
||||
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
|
||||
|
||||
// Sort by hue, then by saturation (more vivid first), then by lightness
|
||||
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
|
||||
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
|
||||
return a.hsl.l - b.hsl.l;
|
||||
})
|
||||
.map((def) => hslToHex(def.hsl));
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
ClockIcon,
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeAmericasIcon,
|
||||
@@ -20,11 +22,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,13 +42,12 @@ 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";
|
||||
import { IncidentStatusPanel } from "~/routes/resources.incidents";
|
||||
import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
accountPath,
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
v3BillingPath,
|
||||
v3BuiltInDashboardPath,
|
||||
v3BulkActionsPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentPath,
|
||||
@@ -88,23 +90,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 +156,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();
|
||||
@@ -147,9 +166,15 @@ export function SideMenu({
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const featureFlags = useFeatureFlags();
|
||||
const incidentStatus = useIncidentStatus();
|
||||
const isV3Project = project.engine === "V1";
|
||||
|
||||
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 +195,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 +217,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 +245,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,295 +287,441 @@ 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="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 && (
|
||||
<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}
|
||||
title={incidentStatus.title}
|
||||
hasIncident={incidentStatus.hasIncident}
|
||||
isManagedCloud={incidentStatus.isManagedCloud}
|
||||
/>
|
||||
<V3DeprecationPanel
|
||||
isCollapsed={isCollapsed}
|
||||
isV3={isV3Project}
|
||||
hasIncident={incidentStatus.hasIncident}
|
||||
isManagedCloud={incidentStatus.isManagedCloud}
|
||||
/>
|
||||
<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"
|
||||
)}
|
||||
<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>
|
||||
>
|
||||
<HelpAndAI isCollapsed={isCollapsed} />
|
||||
{isFreeUser && (
|
||||
<CollapsibleHeight isCollapsed={isCollapsed}>
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
</CollapsibleHeight>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IncidentStatusPanel isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function V3DeprecationPanel({
|
||||
isCollapsed,
|
||||
isV3,
|
||||
hasIncident,
|
||||
isManagedCloud,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
isV3: boolean;
|
||||
hasIncident: boolean;
|
||||
isManagedCloud: boolean;
|
||||
}) {
|
||||
if (!isManagedCloud || !isV3 || hasIncident) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<div className="p-1">
|
||||
<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")}
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<HelpAndAI isCollapsed={isCollapsed} />
|
||||
{isFreeUser && (
|
||||
<CollapsibleHeight isCollapsed={isCollapsed}>
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
</CollapsibleHeight>
|
||||
)}
|
||||
<V3DeprecationContent />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isCollapsed ? "auto" : 0,
|
||||
opacity: isCollapsed ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-amber-500/30 bg-amber-500/15 transition-colors hover:border-amber-500/50 hover:bg-amber-500/25">
|
||||
<ExclamationTriangleIcon className="size-5 text-amber-400" />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content="V3 deprecation warning"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
disableHoverableContent
|
||||
asChild
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
<PopoverContent side="right" sideOffset={8} align="start" className="w-52 !min-w-0 p-0">
|
||||
<V3DeprecationContent />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function V3DeprecationContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded border border-amber-500/30 bg-amber-500/10 p-2 pt-1.5">
|
||||
<div className="flex items-center gap-1 border-b border-amber-500/30 pb-1">
|
||||
<ExclamationTriangleIcon className="size-4 text-amber-400" />
|
||||
<Paragraph variant="small/bright" className="text-amber-300">
|
||||
V3 deprecation warning
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="extra-small/bright" className="text-amber-300">
|
||||
This is a v3 project. V3 deploys will stop working on 1 April 2026. Full shutdown is 1 July
|
||||
2026 where all v3 runs will stop executing. Migrate to v4 to avoid downtime.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to="https://trigger.dev/docs/migrating-from-v3"
|
||||
target="_blank"
|
||||
fullWidth
|
||||
TrailingIcon={ArrowTopRightOnSquareIcon}
|
||||
trailingIconClassName="text-amber-300"
|
||||
className="border-amber-500/30 bg-amber-500/15 hover:!border-amber-500/50 hover:!bg-amber-500/25"
|
||||
>
|
||||
<span className="text-amber-300">View migration guide</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -890,7 +1068,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 +1092,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 +1105,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 +1121,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 +1164,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,191 @@
|
||||
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/tsql";
|
||||
import { Hash } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
BigNumberAggregationType,
|
||||
BigNumberConfiguration,
|
||||
} from "~/components/metrics/QueryWidget";
|
||||
import { createValueFormatter } from "~/utils/columnFormat";
|
||||
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]);
|
||||
|
||||
// Look up column format for format-aware display
|
||||
const columnValueFormatter = useMemo(() => {
|
||||
const columnMeta = columns.find((c) => c.name === column);
|
||||
const formatType = (columnMeta?.format ?? columnMeta?.customRenderType) as
|
||||
| ColumnFormatType
|
||||
| undefined;
|
||||
return createValueFormatter(formatType);
|
||||
}, [columns, column]);
|
||||
|
||||
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" />;
|
||||
}
|
||||
|
||||
// Use format-aware formatter when available
|
||||
if (columnValueFormatter) {
|
||||
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 text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
|
||||
{prefix && <span>{prefix}</span>}
|
||||
<span>{columnValueFormatter(result)}</span>
|
||||
{suffix && <span className="text-[0.4em] text-text-dimmed">{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
|
||||
? abbreviateValue(result)
|
||||
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
|
||||
|
||||
return (
|
||||
<div className="h-full w-full [container-type:size]">
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ const ChartTooltipContent = React.forwardRef<
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
/** Optional formatter for numeric values (e.g. bytes, duration) */
|
||||
valueFormatter?: (value: number) => string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -121,6 +123,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
valueFormatter,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -221,9 +224,11 @@ const ChartTooltipContent = React.forwardRef<
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
{item.value != null && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
{valueFormatter && typeof item.value === "number"
|
||||
? valueFormatter(item.value)
|
||||
: item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
XAxis,
|
||||
@@ -11,20 +10,11 @@ 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";
|
||||
// Legend is now rendered by ChartRoot outside the chart container
|
||||
import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading";
|
||||
import { useHasNoData } from "./ChartRoot";
|
||||
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
|
||||
|
||||
@@ -48,6 +38,8 @@ export type ChartBarRendererProps = {
|
||||
referenceLine?: ReferenceLineProps;
|
||||
/** Custom tooltip label formatter */
|
||||
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
|
||||
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
|
||||
tooltipValueFormatter?: (value: number) => string;
|
||||
/** Width injected by ResponsiveContainer */
|
||||
width?: number;
|
||||
/** Height injected by ResponsiveContainer */
|
||||
@@ -72,10 +64,11 @@ export function ChartBarRenderer({
|
||||
yAxisProps: yAxisPropsProp,
|
||||
referenceLine,
|
||||
tooltipLabelFormatter,
|
||||
tooltipValueFormatter,
|
||||
width,
|
||||
height,
|
||||
}: ChartBarRendererProps) {
|
||||
const { config, data, dataKey, dataKeys, state, highlight, zoom, showLegend } = useChartContext();
|
||||
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, zoom, showLegend } = useChartContext();
|
||||
const hasNoData = useHasNoData();
|
||||
const zoomHandlers = useZoomHandlers();
|
||||
const enableZoom = zoom !== null;
|
||||
@@ -123,9 +116,8 @@ export function ChartBarRenderer({
|
||||
onMouseDown={zoomHandlers.onMouseDown}
|
||||
onMouseMove={(e: any) => {
|
||||
zoomHandlers.onMouseMove?.(e);
|
||||
// Update active payload for legend
|
||||
if (e?.activePayload?.length) {
|
||||
highlight.setActivePayload(e.activePayload);
|
||||
setActivePayload(e.activePayload, e.activeTooltipIndex);
|
||||
highlight.setTooltipActive(true);
|
||||
} else {
|
||||
highlight.setTooltipActive(false);
|
||||
@@ -162,26 +154,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 valueFormatter={tooltipValueFormatter} />
|
||||
) : (
|
||||
<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 && (
|
||||
@@ -194,7 +187,12 @@ export function ChartBarRenderer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{dataKeys.map((key, index, array) => {
|
||||
{visibleSeries.map((key, index, array) => {
|
||||
const dimmed =
|
||||
!zoom?.isSelecting &&
|
||||
highlight.activeBarKey !== null &&
|
||||
highlight.activeBarKey !== key;
|
||||
|
||||
return (
|
||||
<Bar
|
||||
key={key}
|
||||
@@ -210,7 +208,7 @@ export function ChartBarRenderer({
|
||||
] as [number, number, number, number]
|
||||
}
|
||||
activeBar={false}
|
||||
fillOpacity={1}
|
||||
fillOpacity={dimmed ? 0.2 : 1}
|
||||
onClick={(data, index, e) => handleBarClick(data, e)}
|
||||
onMouseEnter={(entry, index) => {
|
||||
if (entry.tooltipPayload?.[0]) {
|
||||
@@ -220,20 +218,7 @@ export function ChartBarRenderer({
|
||||
}}
|
||||
onMouseLeave={highlight.reset}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{data.map((_, dataIndex) => {
|
||||
// Don't dim bars during zoom selection
|
||||
const opacity = zoom?.isSelecting ? 1 : getBarOpacity(key, dataIndex, highlight);
|
||||
|
||||
return (
|
||||
<Cell
|
||||
key={`cell-${key}-${dataIndex}`}
|
||||
fill={config[key]?.color}
|
||||
fillOpacity={opacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import React, { createContext, useContext, useMemo } from "react";
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import type { ChartConfig, ChartState } from "./Chart";
|
||||
import { useHighlightState, type UseHighlightStateReturn } from "./hooks/useHighlightState";
|
||||
import {
|
||||
@@ -17,6 +17,8 @@ export type ChartContextValue = {
|
||||
dataKey: string;
|
||||
/** Computed series keys (all config keys except dataKey) */
|
||||
dataKeys: string[];
|
||||
/** Subset of dataKeys actually rendered as SVG elements (defaults to dataKeys) */
|
||||
visibleSeries: string[];
|
||||
|
||||
// Display state
|
||||
state?: ChartState;
|
||||
@@ -25,9 +27,12 @@ export type ChartContextValue = {
|
||||
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
|
||||
labelFormatter?: LabelFormatter;
|
||||
|
||||
// Highlight state
|
||||
// Highlight state (does NOT include activePayload — see PayloadContext)
|
||||
highlight: UseHighlightStateReturn;
|
||||
|
||||
/** Update the active payload for the legend. Pass tooltipIndex to skip redundant updates. */
|
||||
setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void;
|
||||
|
||||
// Zoom state (only present when zoom is enabled)
|
||||
zoom: UseZoomSelectionReturn | null;
|
||||
|
||||
@@ -40,6 +45,12 @@ export type ChartContextValue = {
|
||||
|
||||
const ChartCompoundContext = createContext<ChartContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Separate context for activePayload so that frequent payload updates
|
||||
* only re-render the legend, not the entire chart (bars, lines, etc.).
|
||||
*/
|
||||
const PayloadContext = createContext<any[] | null>(null);
|
||||
|
||||
export function useChartContext(): ChartContextValue {
|
||||
const context = useContext(ChartCompoundContext);
|
||||
if (!context) {
|
||||
@@ -48,12 +59,19 @@ export function useChartContext(): ChartContextValue {
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Read the active payload (only re-renders when payload changes). */
|
||||
export function useActivePayload(): any[] | null {
|
||||
return useContext(PayloadContext);
|
||||
}
|
||||
|
||||
export type ChartProviderProps = {
|
||||
config: ChartConfig;
|
||||
data: any[];
|
||||
dataKey: string;
|
||||
/** Series keys to render (if not provided, derived from config keys) */
|
||||
series?: string[];
|
||||
/** Subset of series to render as SVG elements on the chart (legend still shows all series) */
|
||||
visibleSeries?: string[];
|
||||
state?: ChartState;
|
||||
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
|
||||
labelFormatter?: LabelFormatter;
|
||||
@@ -71,6 +89,7 @@ export function ChartProvider({
|
||||
data,
|
||||
dataKey,
|
||||
series,
|
||||
visibleSeries: visibleSeriesProp,
|
||||
state,
|
||||
labelFormatter,
|
||||
enableZoom = false,
|
||||
@@ -81,27 +100,67 @@ export function ChartProvider({
|
||||
const highlight = useHighlightState();
|
||||
const zoomState = useZoomSelection();
|
||||
|
||||
// activePayload lives in its own state + context so updates don't re-render bars
|
||||
const [activePayload, setActivePayloadRaw] = useState<any[] | null>(null);
|
||||
const activeTooltipIndexRef = useRef<number | null>(null);
|
||||
|
||||
const setActivePayload = useCallback(
|
||||
(payload: any[] | null, tooltipIndex?: number | null) => {
|
||||
const idx = tooltipIndex ?? null;
|
||||
if (idx !== null && idx === activeTooltipIndexRef.current) {
|
||||
return;
|
||||
}
|
||||
activeTooltipIndexRef.current = idx;
|
||||
setActivePayloadRaw(payload);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset the tooltip index ref when highlight resets (mouse leaves chart)
|
||||
const originalReset = highlight.reset;
|
||||
const resetWithPayload = useCallback(() => {
|
||||
activeTooltipIndexRef.current = null;
|
||||
setActivePayloadRaw(null);
|
||||
originalReset();
|
||||
}, [originalReset]);
|
||||
|
||||
const highlightWithReset = useMemo(
|
||||
() => ({ ...highlight, reset: resetWithPayload }),
|
||||
[highlight, resetWithPayload]
|
||||
);
|
||||
|
||||
// Compute series keys (use provided series or derive from config)
|
||||
const dataKeys = useMemo(
|
||||
() => series ?? Object.keys(config).filter((k) => k !== dataKey),
|
||||
[series, config, dataKey]
|
||||
);
|
||||
|
||||
const visibleSeries = useMemo(
|
||||
() => visibleSeriesProp ?? dataKeys,
|
||||
[visibleSeriesProp, dataKeys]
|
||||
);
|
||||
|
||||
const value = useMemo<ChartContextValue>(
|
||||
() => ({
|
||||
config,
|
||||
data,
|
||||
dataKey,
|
||||
dataKeys,
|
||||
visibleSeries,
|
||||
state,
|
||||
labelFormatter,
|
||||
highlight,
|
||||
highlight: highlightWithReset,
|
||||
setActivePayload,
|
||||
zoom: enableZoom ? zoomState : null,
|
||||
onZoomChange: enableZoom ? onZoomChange : undefined,
|
||||
showLegend,
|
||||
}),
|
||||
[config, data, dataKey, dataKeys, state, labelFormatter, highlight, zoomState, enableZoom, onZoomChange, showLegend]
|
||||
[config, data, dataKey, dataKeys, visibleSeries, state, labelFormatter, highlightWithReset, setActivePayload, zoomState, enableZoom, onZoomChange, showLegend]
|
||||
);
|
||||
|
||||
return <ChartCompoundContext.Provider value={value}>{children}</ChartCompoundContext.Provider>;
|
||||
return (
|
||||
<ChartCompoundContext.Provider value={value}>
|
||||
<PayloadContext.Provider value={activePayload}>{children}</PayloadContext.Provider>
|
||||
</ChartCompoundContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useChartContext } from "./ChartContext";
|
||||
import type { AggregationType } from "~/components/metrics/QueryWidget";
|
||||
import { useActivePayload, 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,12 @@ 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;
|
||||
/** Optional formatter for numeric values (e.g. bytes, duration) */
|
||||
valueFormatter?: (value: number) => string;
|
||||
/** Callback when "View all" button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
@@ -37,64 +50,90 @@ export function ChartLegendCompound({
|
||||
maxItems = Infinity,
|
||||
hidden = false,
|
||||
className,
|
||||
totalLabel = "Total",
|
||||
totalLabel,
|
||||
aggregation,
|
||||
valueFormatter,
|
||||
onViewAllLegendItems,
|
||||
scrollable = false,
|
||||
}: ChartLegendCompoundProps) {
|
||||
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
|
||||
const totals = useSeriesTotal();
|
||||
const activePayload = useActivePayload();
|
||||
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(() => {
|
||||
if (!highlight.activePayload?.length) return grandTotal;
|
||||
// Calculate current total based on hover state (null when hovering a gap-filled point)
|
||||
const currentTotal = useMemo((): number | null => {
|
||||
if (!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 = 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);
|
||||
}, [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 (!activePayload?.length) return effectiveTotalLabel;
|
||||
|
||||
// Get the x-axis label from the payload's original data
|
||||
const firstPayloadItem = highlight.activePayload[0];
|
||||
const firstPayloadItem = 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]);
|
||||
}, [activePayload, dataKey, effectiveTotalLabel, labelFormatter]);
|
||||
|
||||
// Get current data for the legend based on hover state
|
||||
const currentData = useMemo(() => {
|
||||
if (!highlight.activePayload?.length) return totals;
|
||||
// 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 (!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 = 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 {
|
||||
...totals,
|
||||
...hoverData,
|
||||
};
|
||||
}, [highlight.activePayload, totals]);
|
||||
}, [activePayload, totals]);
|
||||
|
||||
// Prepare legend items with capped display
|
||||
const legendItems = useMemo(() => {
|
||||
@@ -128,15 +167,11 @@ export function ChartLegendCompound({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHovering = (highlight.activePayload?.length ?? 0) > 0;
|
||||
const isHovering = (activePayload?.length ?? 0) > 0;
|
||||
|
||||
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 +182,15 @@ export function ChartLegendCompound({
|
||||
>
|
||||
<span className="font-medium">{currentTotalLabel}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
<AnimatedNumber value={currentTotal} duration={0.25} />
|
||||
{currentTotal != null ? (
|
||||
valueFormatter ? (
|
||||
valueFormatter(currentTotal)
|
||||
) : (
|
||||
<AnimatedNumber value={currentTotal} duration={0.25} />
|
||||
)
|
||||
) : (
|
||||
"\u2013"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -155,17 +198,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 +226,47 @@ 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 ? (
|
||||
valueFormatter ? (
|
||||
valueFormatter(total)
|
||||
) : (
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
)
|
||||
) : (
|
||||
"\u2013"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,11 +278,15 @@ 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}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
) : (
|
||||
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
|
||||
<ViewAllDataRow
|
||||
remainingCount={legendItems.remaining}
|
||||
onViewAll={onViewAllLegendItems}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,33 +300,37 @@ 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;
|
||||
valueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItemRowProps) {
|
||||
function HoveredHiddenItemRow({ item, value, remainingCount, valueFormatter }: HoveredHiddenItemRowProps) {
|
||||
return (
|
||||
<div className="relative flex w-full items-center justify-between gap-2 rounded px-2 py-1">
|
||||
{/* Active highlight background */}
|
||||
@@ -261,11 +340,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 +352,15 @@ 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 ? (
|
||||
valueFormatter ? (
|
||||
valueFormatter(value)
|
||||
) : (
|
||||
<AnimatedNumber value={value} duration={0.25} />
|
||||
)
|
||||
) : (
|
||||
"\u2013"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,8 @@ export type ChartLineRendererProps = {
|
||||
stacked?: boolean;
|
||||
/** Custom tooltip label formatter */
|
||||
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
|
||||
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
|
||||
tooltipValueFormatter?: (value: number) => string;
|
||||
/** Width injected by ResponsiveContainer */
|
||||
width?: number;
|
||||
/** Height injected by ResponsiveContainer */
|
||||
@@ -75,10 +77,11 @@ export function ChartLineRenderer({
|
||||
yAxisProps: yAxisPropsProp,
|
||||
stacked = false,
|
||||
tooltipLabelFormatter,
|
||||
tooltipValueFormatter,
|
||||
width,
|
||||
height,
|
||||
}: ChartLineRendererProps) {
|
||||
const { config, data, dataKey, dataKeys, state, highlight, showLegend } = useChartContext();
|
||||
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, showLegend } = useChartContext();
|
||||
const hasNoData = useHasNoData();
|
||||
|
||||
// Render loading/error states
|
||||
@@ -130,7 +133,7 @@ export function ChartLineRenderer({
|
||||
};
|
||||
|
||||
// Render stacked area chart if stacked prop is true
|
||||
if (stacked && dataKeys.length > 1) {
|
||||
if (stacked && visibleSeries.length > 1) {
|
||||
return (
|
||||
<AreaChart
|
||||
data={data}
|
||||
@@ -142,9 +145,8 @@ export function ChartLineRenderer({
|
||||
right: 12,
|
||||
}}
|
||||
onMouseMove={(e: any) => {
|
||||
// Update active payload for legend
|
||||
if (e?.activePayload?.length) {
|
||||
highlight.setActivePayload(e.activePayload);
|
||||
setActivePayload(e.activePayload, e.activeTooltipIndex);
|
||||
highlight.setTooltipActive(true);
|
||||
} else {
|
||||
highlight.setTooltipActive(false);
|
||||
@@ -155,16 +157,20 @@ 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" valueFormatter={tooltipValueFormatter} />
|
||||
)
|
||||
}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
{dataKeys.map((key) => (
|
||||
{visibleSeries.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
type={lineType}
|
||||
@@ -188,13 +194,13 @@ export function ChartLineRenderer({
|
||||
width={width}
|
||||
height={height}
|
||||
margin={{
|
||||
top: 5,
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
onMouseMove={(e: any) => {
|
||||
// Update active payload for legend
|
||||
if (e?.activePayload?.length) {
|
||||
highlight.setActivePayload(e.activePayload);
|
||||
setActivePayload(e.activePayload, e.activeTooltipIndex);
|
||||
highlight.setTooltipActive(true);
|
||||
} else {
|
||||
highlight.setTooltipActive(false);
|
||||
@@ -205,23 +211,27 @@ 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 valueFormatter={tooltipValueFormatter} />
|
||||
)
|
||||
}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
{dataKeys.map((key) => (
|
||||
{visibleSeries.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
dataKey={key}
|
||||
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";
|
||||
@@ -12,6 +13,8 @@ export type ChartRootProps = {
|
||||
dataKey: string;
|
||||
/** Series keys to render (if not provided, derived from config keys) */
|
||||
series?: string[];
|
||||
/** Subset of series to render as SVG elements on the chart (legend still shows all series) */
|
||||
visibleSeries?: string[];
|
||||
state?: ChartState;
|
||||
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
|
||||
labelFormatter?: LabelFormatter;
|
||||
@@ -29,12 +32,18 @@ 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;
|
||||
/** Optional formatter for numeric legend values (e.g. bytes, duration) */
|
||||
legendValueFormatter?: (value: number) => string;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
legendScrollable?: boolean;
|
||||
/** When true, chart fills its parent container height and distributes space between chart and legend */
|
||||
fillContainer?: boolean;
|
||||
/** Content rendered between the chart and the legend */
|
||||
beforeLegend?: React.ReactNode;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
};
|
||||
|
||||
@@ -64,6 +73,7 @@ export function ChartRoot({
|
||||
data,
|
||||
dataKey,
|
||||
series,
|
||||
visibleSeries,
|
||||
state,
|
||||
labelFormatter,
|
||||
enableZoom = false,
|
||||
@@ -73,9 +83,12 @@ export function ChartRoot({
|
||||
showLegend = false,
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
legendAggregation,
|
||||
legendValueFormatter,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
beforeLegend,
|
||||
children,
|
||||
}: ChartRootProps) {
|
||||
return (
|
||||
@@ -84,6 +97,7 @@ export function ChartRoot({
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
series={series}
|
||||
visibleSeries={visibleSeries}
|
||||
state={state}
|
||||
labelFormatter={labelFormatter}
|
||||
enableZoom={enableZoom}
|
||||
@@ -96,9 +110,12 @@ export function ChartRoot({
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={maxLegendItems}
|
||||
legendTotalLabel={legendTotalLabel}
|
||||
legendAggregation={legendAggregation}
|
||||
legendValueFormatter={legendValueFormatter}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
fillContainer={fillContainer}
|
||||
beforeLegend={beforeLegend}
|
||||
>
|
||||
{children}
|
||||
</ChartRootInner>
|
||||
@@ -112,9 +129,12 @@ type ChartRootInnerProps = {
|
||||
showLegend?: boolean;
|
||||
maxLegendItems?: number;
|
||||
legendTotalLabel?: string;
|
||||
legendAggregation?: AggregationType;
|
||||
legendValueFormatter?: (value: number) => string;
|
||||
onViewAllLegendItems?: () => void;
|
||||
legendScrollable?: boolean;
|
||||
fillContainer?: boolean;
|
||||
beforeLegend?: React.ReactNode;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
};
|
||||
|
||||
@@ -124,9 +144,12 @@ function ChartRootInner({
|
||||
showLegend = false,
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
legendAggregation,
|
||||
legendValueFormatter,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
beforeLegend,
|
||||
children,
|
||||
}: ChartRootInnerProps) {
|
||||
const { config, zoom } = useChartContext();
|
||||
@@ -160,11 +183,14 @@ function ChartRootInner({
|
||||
{children}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
{beforeLegend}
|
||||
{/* Legend rendered outside the chart container */}
|
||||
{showLegend && (
|
||||
<ChartLegendCompound
|
||||
maxItems={maxLegendItems}
|
||||
totalLabel={legendTotalLabel}
|
||||
aggregation={legendAggregation}
|
||||
valueFormatter={legendValueFormatter}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
scrollable={legendScrollable}
|
||||
/>
|
||||
@@ -194,18 +220,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);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export type HighlightState = {
|
||||
/** The currently highlighted series key (e.g., "completed", "failed") */
|
||||
activeBarKey: string | null;
|
||||
/** The index of the specific data point being hovered (null when hovering legend) */
|
||||
activeDataPointIndex: number | null;
|
||||
/** The payload data from the hovered element */
|
||||
activePayload: any[] | null;
|
||||
/** Whether the tooltip is currently active */
|
||||
tooltipActive: boolean;
|
||||
};
|
||||
|
||||
export type HighlightActions = {
|
||||
/** Set the hovered bar (specific data point) */
|
||||
setHoveredBar: (key: string, index: number, payload?: any[]) => void;
|
||||
setHoveredBar: (key: string, index: number) => void;
|
||||
/** Set the hovered legend item (highlights all bars of that type) */
|
||||
setHoveredLegendItem: (key: string) => void;
|
||||
/** Set the active payload (for tooltip data) */
|
||||
setActivePayload: (payload: any[] | null) => void;
|
||||
/** Set tooltip active state */
|
||||
setTooltipActive: (active: boolean) => void;
|
||||
/** Reset all highlight state */
|
||||
@@ -29,60 +25,55 @@ export type UseHighlightStateReturn = HighlightState & HighlightActions;
|
||||
const initialState: HighlightState = {
|
||||
activeBarKey: null,
|
||||
activeDataPointIndex: null,
|
||||
activePayload: null,
|
||||
tooltipActive: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage highlight state for chart elements.
|
||||
* Handles both bar hover (specific data point) and legend hover (all bars of a type).
|
||||
*
|
||||
* activePayload is intentionally NOT managed here — it lives in a separate context
|
||||
* so that payload updates (frequent during mouse movement) don't cause bar re-renders.
|
||||
*/
|
||||
export function useHighlightState(): UseHighlightStateReturn {
|
||||
const [state, setState] = useState<HighlightState>(initialState);
|
||||
|
||||
const setHoveredBar = useCallback((key: string, index: number, payload?: any[]) => {
|
||||
const setHoveredBar = useCallback((key: string, index: number) => {
|
||||
setState({
|
||||
activeBarKey: key,
|
||||
activeDataPointIndex: index,
|
||||
activePayload: payload ?? null,
|
||||
tooltipActive: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setHoveredLegendItem = useCallback((key: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
activeBarKey: key,
|
||||
activeDataPointIndex: null, // null indicates legend hover (all bars of this type)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setActivePayload = useCallback((payload: any[] | null) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
activePayload: payload,
|
||||
}));
|
||||
setState((prev) => {
|
||||
if (prev.activeBarKey === key && prev.activeDataPointIndex === null) return prev;
|
||||
return { ...prev, activeBarKey: key, activeDataPointIndex: null };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setTooltipActive = useCallback((active: boolean) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tooltipActive: active,
|
||||
}));
|
||||
setState((prev) => {
|
||||
if (prev.tooltipActive === active) return prev;
|
||||
return { ...prev, tooltipActive: active };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
setHoveredBar,
|
||||
setHoveredLegendItem,
|
||||
setActivePayload,
|
||||
setTooltipActive,
|
||||
reset,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
setHoveredBar,
|
||||
setHoveredLegendItem,
|
||||
setTooltipActive,
|
||||
reset,
|
||||
}),
|
||||
[state, setHoveredBar, setHoveredLegendItem, setTooltipActive, reset]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
export type ZoomRange = {
|
||||
start: string;
|
||||
@@ -175,14 +175,17 @@ export function useZoomSelection(): UseZoomSelectionReturn {
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
startSelection,
|
||||
updateSelection,
|
||||
finishSelection,
|
||||
cancelSelection,
|
||||
toggleInspectionLine,
|
||||
clearInspectionLine,
|
||||
reset,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
startSelection,
|
||||
updateSelection,
|
||||
finishSelection,
|
||||
cancelSelection,
|
||||
toggleInspectionLine,
|
||||
clearInspectionLine,
|
||||
reset,
|
||||
}),
|
||||
[state, startSelection, updateSelection, finishSelection, cancelSelection, toggleInspectionLine, clearInspectionLine, reset]
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -372,6 +372,7 @@ const EnvironmentSchema = z
|
||||
|
||||
// Development OTEL environment variables
|
||||
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
||||
DEV_OTEL_METRICS_ENDPOINT: z.string().optional(),
|
||||
// If this is set to 1, then the below variables are used to configure the batch processor for spans and logs
|
||||
DEV_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
|
||||
DEV_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
|
||||
@@ -382,6 +383,9 @@ const EnvironmentSchema = z
|
||||
DEV_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
|
||||
DEV_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
|
||||
DEV_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
|
||||
DEV_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
|
||||
DEV_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
|
||||
DEV_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
|
||||
|
||||
PROD_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
|
||||
PROD_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
|
||||
@@ -392,6 +396,9 @@ const EnvironmentSchema = z
|
||||
PROD_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
|
||||
PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
|
||||
PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
|
||||
PROD_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
|
||||
PROD_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
|
||||
PROD_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
|
||||
|
||||
TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
|
||||
TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
|
||||
@@ -1180,7 +1187,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 +1197,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 +1220,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()
|
||||
@@ -1221,6 +1236,9 @@ const EnvironmentSchema = z
|
||||
EVENTS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
|
||||
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),
|
||||
METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
METRICS_CLICKHOUSE_MAX_CONCURRENCY: z.coerce.number().int().default(3),
|
||||
EVENTS_CLICKHOUSE_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
|
||||
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import { useReducer, useCallback, useRef, useEffect } from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import type {
|
||||
DashboardLayout,
|
||||
LayoutItem,
|
||||
Widget,
|
||||
} from "~/presenters/v3/MetricDashboardPresenter.server";
|
||||
import type { WidgetData, QueryWidgetConfig } from "~/components/metrics/QueryWidget";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type EditorMode =
|
||||
| null
|
||||
| { type: "add" }
|
||||
| { type: "edit"; widgetId: string; widget: WidgetData };
|
||||
|
||||
type DashboardState = {
|
||||
/** The layout items (positions/sizes) */
|
||||
layout: LayoutItem[];
|
||||
/** The widget configurations keyed by widget ID */
|
||||
widgets: Record<string, Widget>;
|
||||
/** Current editor mode (add/edit/closed) */
|
||||
editorMode: EditorMode;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Actions
|
||||
// ============================================================================
|
||||
|
||||
type DashboardAction =
|
||||
| { type: "ADD_WIDGET"; payload: { id: string; widget: Widget; layoutItem: LayoutItem } }
|
||||
| { type: "UPDATE_WIDGET"; payload: { id: string; widget: Widget } }
|
||||
| { type: "RENAME_WIDGET"; payload: { id: string; title: string } }
|
||||
| { type: "DELETE_WIDGET"; payload: { id: string } }
|
||||
| { type: "DUPLICATE_WIDGET"; payload: { id: string; newId: string } }
|
||||
| { type: "UPDATE_LAYOUT"; payload: { layout: LayoutItem[] } }
|
||||
| { type: "RESET_STATE"; payload: { layout: LayoutItem[]; widgets: Record<string, Widget> } }
|
||||
| { type: "OPEN_ADD_EDITOR" }
|
||||
| { type: "OPEN_EDIT_EDITOR"; payload: { widgetId: string; widget: WidgetData } }
|
||||
| { type: "CLOSE_EDITOR" };
|
||||
|
||||
// ============================================================================
|
||||
// Reducer
|
||||
// ============================================================================
|
||||
|
||||
function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
|
||||
switch (action.type) {
|
||||
case "ADD_WIDGET":
|
||||
return {
|
||||
...state,
|
||||
layout: [...state.layout, action.payload.layoutItem],
|
||||
widgets: {
|
||||
...state.widgets,
|
||||
[action.payload.id]: action.payload.widget,
|
||||
},
|
||||
editorMode: null,
|
||||
};
|
||||
|
||||
case "UPDATE_WIDGET":
|
||||
return {
|
||||
...state,
|
||||
widgets: {
|
||||
...state.widgets,
|
||||
[action.payload.id]: action.payload.widget,
|
||||
},
|
||||
editorMode: null,
|
||||
};
|
||||
|
||||
case "RENAME_WIDGET": {
|
||||
const existingWidget = state.widgets[action.payload.id];
|
||||
if (!existingWidget) return state;
|
||||
return {
|
||||
...state,
|
||||
widgets: {
|
||||
...state.widgets,
|
||||
[action.payload.id]: {
|
||||
...existingWidget,
|
||||
title: action.payload.title,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "DELETE_WIDGET": {
|
||||
const { [action.payload.id]: _, ...remainingWidgets } = state.widgets;
|
||||
return {
|
||||
...state,
|
||||
layout: state.layout.filter((item) => item.i !== action.payload.id),
|
||||
widgets: remainingWidgets,
|
||||
};
|
||||
}
|
||||
|
||||
case "DUPLICATE_WIDGET": {
|
||||
const original = state.widgets[action.payload.id];
|
||||
const originalLayout = state.layout.find((l) => l.i === action.payload.id);
|
||||
if (!original || !originalLayout) return state;
|
||||
|
||||
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
|
||||
|
||||
// Deep copy the widget to ensure no shared references
|
||||
// This prevents edits to one widget from affecting the duplicate
|
||||
const duplicatedWidget: Widget = {
|
||||
title: `${original.title} (Copy)`,
|
||||
query: original.query,
|
||||
display: JSON.parse(JSON.stringify(original.display)) as QueryWidgetConfig,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
layout: [
|
||||
...state.layout,
|
||||
{ ...originalLayout, i: action.payload.newId, y: maxBottom, x: 0 },
|
||||
],
|
||||
widgets: {
|
||||
...state.widgets,
|
||||
[action.payload.newId]: duplicatedWidget,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "UPDATE_LAYOUT":
|
||||
return { ...state, layout: action.payload.layout };
|
||||
|
||||
case "RESET_STATE":
|
||||
return {
|
||||
...state,
|
||||
layout: action.payload.layout,
|
||||
widgets: action.payload.widgets,
|
||||
};
|
||||
|
||||
case "OPEN_ADD_EDITOR":
|
||||
return { ...state, editorMode: { type: "add" } };
|
||||
|
||||
case "OPEN_EDIT_EDITOR":
|
||||
return {
|
||||
...state,
|
||||
editorMode: { type: "edit", widgetId: action.payload.widgetId, widget: action.payload.widget },
|
||||
};
|
||||
|
||||
case "CLOSE_EDITOR":
|
||||
return { ...state, editorMode: null };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Options
|
||||
// ============================================================================
|
||||
|
||||
export type UseDashboardEditorOptions = {
|
||||
/** Initial dashboard layout data from the server */
|
||||
initialData: DashboardLayout;
|
||||
/** URL for widget actions (add, update, delete, duplicate) */
|
||||
widgetActionUrl: string;
|
||||
/** URL for layout updates. If empty or not provided, uses current page URL. */
|
||||
layoutActionUrl?: string;
|
||||
/** Maximum number of widgets allowed per dashboard. If not provided, no limit is enforced. */
|
||||
widgetLimit?: number;
|
||||
/** Callback when a sync error occurs */
|
||||
onSyncError?: (error: Error, action: string) => void;
|
||||
/** Callback when a widget action is blocked by the limit */
|
||||
onWidgetLimitReached?: () => void;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sync Queue Types
|
||||
// ============================================================================
|
||||
|
||||
type WidgetSyncTask = {
|
||||
type: "widget";
|
||||
action: string;
|
||||
data: Record<string, string>;
|
||||
};
|
||||
|
||||
type LayoutSyncTask = {
|
||||
type: "layout";
|
||||
layout: LayoutItem[];
|
||||
};
|
||||
|
||||
type SyncTask = WidgetSyncTask | LayoutSyncTask;
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useDashboardEditor({
|
||||
initialData,
|
||||
widgetActionUrl,
|
||||
layoutActionUrl,
|
||||
widgetLimit,
|
||||
onSyncError,
|
||||
onWidgetLimitReached,
|
||||
}: UseDashboardEditorOptions) {
|
||||
const [state, dispatch] = useReducer(dashboardReducer, {
|
||||
layout: initialData.layout,
|
||||
widgets: initialData.widgets,
|
||||
editorMode: null,
|
||||
});
|
||||
|
||||
// Refs for debouncing and tracking initialization
|
||||
const layoutDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
const currentLayoutJsonRef = useRef<string>(JSON.stringify(initialData.layout));
|
||||
|
||||
// Sync queue to prevent race conditions
|
||||
const syncQueueRef = useRef<SyncTask[]>([]);
|
||||
const isSyncingRef = useRef(false);
|
||||
|
||||
// Reset state when initialData changes (e.g., navigating to different dashboard)
|
||||
const initialDataJson = JSON.stringify({ layout: initialData.layout, widgets: initialData.widgets });
|
||||
useEffect(() => {
|
||||
// Cancel any pending layout save
|
||||
if (layoutDebounceRef.current) {
|
||||
clearTimeout(layoutDebounceRef.current);
|
||||
layoutDebounceRef.current = null;
|
||||
}
|
||||
|
||||
// Clear the sync queue when switching dashboards
|
||||
syncQueueRef.current = [];
|
||||
|
||||
// Reset state to new initial data
|
||||
dispatch({
|
||||
type: "RESET_STATE",
|
||||
payload: { layout: initialData.layout, widgets: initialData.widgets },
|
||||
});
|
||||
|
||||
// Update refs
|
||||
currentLayoutJsonRef.current = JSON.stringify(initialData.layout);
|
||||
isInitializedRef.current = false;
|
||||
|
||||
// Allow saves after a short delay to skip initial mount callbacks
|
||||
const initTimeout = setTimeout(() => {
|
||||
isInitializedRef.current = true;
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimeout);
|
||||
if (layoutDebounceRef.current) {
|
||||
clearTimeout(layoutDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [initialDataJson]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sync queue processor - ensures only one sync runs at a time
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const processNextSync = useCallback(async () => {
|
||||
// If already syncing or queue is empty, do nothing
|
||||
if (isSyncingRef.current || syncQueueRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSyncingRef.current = true;
|
||||
const task = syncQueueRef.current.shift()!;
|
||||
|
||||
try {
|
||||
if (task.type === "widget") {
|
||||
const formData = new FormData();
|
||||
formData.set("action", task.action);
|
||||
Object.entries(task.data).forEach(([k, v]) => formData.set(k, v));
|
||||
|
||||
const response = await fetch(widgetActionUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to ${task.action} widget: ${errorText}`);
|
||||
}
|
||||
} else if (task.type === "layout") {
|
||||
const formData = new FormData();
|
||||
formData.set("action", "layout");
|
||||
formData.set("layout", JSON.stringify(task.layout));
|
||||
|
||||
// Use current page URL if layoutActionUrl is not provided
|
||||
const url = layoutActionUrl || window.location.pathname;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error("Failed to update layout: " + errorText);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Dashboard sync error:`, error);
|
||||
const actionName = task.type === "widget" ? task.action : "layout";
|
||||
onSyncError?.(error instanceof Error ? error : new Error(String(error)), actionName);
|
||||
} finally {
|
||||
isSyncingRef.current = false;
|
||||
// Process next item in queue
|
||||
processNextSync();
|
||||
}
|
||||
}, [widgetActionUrl, layoutActionUrl, onSyncError]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Queue helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const queueWidgetSync = useCallback(
|
||||
(action: string, data: Record<string, string>) => {
|
||||
syncQueueRef.current.push({ type: "widget", action, data });
|
||||
processNextSync();
|
||||
},
|
||||
[processNextSync]
|
||||
);
|
||||
|
||||
const queueLayoutSync = useCallback(
|
||||
(layout: LayoutItem[]) => {
|
||||
// For layout syncs, we only care about the latest state
|
||||
// Remove any pending layout syncs and add the new one
|
||||
syncQueueRef.current = syncQueueRef.current.filter((task) => task.type !== "layout");
|
||||
syncQueueRef.current.push({ type: "layout", layout });
|
||||
processNextSync();
|
||||
},
|
||||
[processNextSync]
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Action handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Count only non-title widgets for limit checks (title widgets are free)
|
||||
const countedWidgets = Object.values(state.widgets).filter(
|
||||
(w) => w.display.type !== "title"
|
||||
).length;
|
||||
|
||||
const addWidget = useCallback(
|
||||
(title: string, query: string, config: QueryWidgetConfig) => {
|
||||
// Guard: check widget limit (title widgets don't count)
|
||||
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
|
||||
onWidgetLimitReached?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = nanoid(8);
|
||||
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
|
||||
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 15 };
|
||||
const widget: Widget = { title, query, display: config };
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
// Send the client-generated ID so the server uses the same ID
|
||||
queueWidgetSync("add", {
|
||||
widgetId: id,
|
||||
title,
|
||||
query,
|
||||
config: JSON.stringify(config),
|
||||
});
|
||||
},
|
||||
[state.layout, countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
|
||||
);
|
||||
|
||||
const addTitleWidget = useCallback(
|
||||
(title: string) => {
|
||||
const id = nanoid(8);
|
||||
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
|
||||
// Title widgets are fixed at h=2 and full width
|
||||
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 2 };
|
||||
const config: QueryWidgetConfig = { type: "title" };
|
||||
const widget: Widget = { title, query: "", display: config };
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
// Send the client-generated ID so the server uses the same ID
|
||||
queueWidgetSync("add", {
|
||||
widgetId: id,
|
||||
title,
|
||||
query: "",
|
||||
config: JSON.stringify(config),
|
||||
});
|
||||
},
|
||||
[state.layout, queueWidgetSync]
|
||||
);
|
||||
|
||||
const updateWidget = useCallback(
|
||||
(widgetId: string, title: string, query: string, config: QueryWidgetConfig) => {
|
||||
const widget: Widget = { title, query, display: config };
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "UPDATE_WIDGET", payload: { id: widgetId, widget } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
queueWidgetSync("update", {
|
||||
widgetId,
|
||||
title,
|
||||
query,
|
||||
config: JSON.stringify(config),
|
||||
});
|
||||
},
|
||||
[queueWidgetSync]
|
||||
);
|
||||
|
||||
const deleteWidget = useCallback(
|
||||
(widgetId: string) => {
|
||||
// Update local state immediately
|
||||
dispatch({ type: "DELETE_WIDGET", payload: { id: widgetId } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
queueWidgetSync("delete", { widgetId });
|
||||
},
|
||||
[queueWidgetSync]
|
||||
);
|
||||
|
||||
const duplicateWidget = useCallback(
|
||||
(widgetId: string) => {
|
||||
// Guard: check widget limit (title widgets don't count)
|
||||
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
|
||||
onWidgetLimitReached?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = nanoid(8);
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "DUPLICATE_WIDGET", payload: { id: widgetId, newId } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
// Send the client-generated newId so the server uses the same ID for the duplicate
|
||||
queueWidgetSync("duplicate", { widgetId, newId });
|
||||
},
|
||||
[countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
|
||||
);
|
||||
|
||||
const renameWidget = useCallback(
|
||||
(widgetId: string, title: string) => {
|
||||
// Update local state immediately
|
||||
dispatch({ type: "RENAME_WIDGET", payload: { id: widgetId, title } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
queueWidgetSync("rename", { widgetId, title });
|
||||
},
|
||||
[queueWidgetSync]
|
||||
);
|
||||
|
||||
const updateLayout = useCallback(
|
||||
(newLayout: LayoutItem[]) => {
|
||||
// Skip if not yet initialized (prevents saving during mount/navigation)
|
||||
if (!isInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLayoutJson = JSON.stringify(newLayout);
|
||||
|
||||
// Skip if layout hasn't actually changed
|
||||
if (newLayoutJson === currentLayoutJsonRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "UPDATE_LAYOUT", payload: { layout: newLayout } });
|
||||
|
||||
// Clear existing debounce timeout
|
||||
if (layoutDebounceRef.current) {
|
||||
clearTimeout(layoutDebounceRef.current);
|
||||
}
|
||||
|
||||
// Debounce before queueing - this ensures rapid layout changes
|
||||
// (like dragging) don't queue up many requests
|
||||
layoutDebounceRef.current = setTimeout(() => {
|
||||
currentLayoutJsonRef.current = newLayoutJson;
|
||||
// Queue layout sync (replaces any pending layout sync in queue)
|
||||
queueLayoutSync(newLayout);
|
||||
}, 500);
|
||||
},
|
||||
[queueLayoutSync]
|
||||
);
|
||||
|
||||
const openAddEditor = useCallback(() => {
|
||||
dispatch({ type: "OPEN_ADD_EDITOR" });
|
||||
}, []);
|
||||
|
||||
const openEditEditor = useCallback((widgetId: string, widget: WidgetData) => {
|
||||
dispatch({ type: "OPEN_EDIT_EDITOR", payload: { widgetId, widget } });
|
||||
}, []);
|
||||
|
||||
const closeEditor = useCallback(() => {
|
||||
dispatch({ type: "CLOSE_EDITOR" });
|
||||
}, []);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Return value
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
/** Current dashboard state */
|
||||
state,
|
||||
/** Action dispatchers */
|
||||
actions: {
|
||||
addWidget,
|
||||
addTitleWidget,
|
||||
updateWidget,
|
||||
renameWidget,
|
||||
deleteWidget,
|
||||
duplicateWidget,
|
||||
updateLayout,
|
||||
openAddEditor,
|
||||
openEditEditor,
|
||||
closeEditor,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type UseElementVisibilityOptions = {
|
||||
onVisibilityChange?: (isVisible: boolean) => void;
|
||||
};
|
||||
|
||||
export function useElementVisibility({
|
||||
onVisibilityChange,
|
||||
}: UseElementVisibilityOptions = {}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisibleRef = useRef(false);
|
||||
const callbackRef = useRef(onVisibilityChange);
|
||||
callbackRef.current = onVisibilityChange;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const nowVisible = entry.isIntersecting;
|
||||
if (isVisibleRef.current !== nowVisible) {
|
||||
isVisibleRef.current = nowVisible;
|
||||
callbackRef.current?.(nowVisible);
|
||||
}
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { ref, isVisibleRef };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type UseIntervalOptions = {
|
||||
/** If passed, will refresh every interval MS */
|
||||
interval?: number;
|
||||
onLoad?: boolean;
|
||||
onFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
callback: () => void;
|
||||
};
|
||||
|
||||
export function useInterval({
|
||||
interval,
|
||||
onLoad = true,
|
||||
onFocus = true,
|
||||
disabled = false,
|
||||
callback,
|
||||
}: UseIntervalOptions) {
|
||||
// Always keep the latest callback in a ref so the effects below
|
||||
// never close over a stale version.
|
||||
const latestCallback = useRef(callback);
|
||||
useEffect(() => {
|
||||
latestCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// On interval
|
||||
useEffect(() => {
|
||||
if (!interval || interval <= 0 || disabled) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
latestCallback.current();
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [interval, disabled]);
|
||||
|
||||
// On focus
|
||||
useEffect(() => {
|
||||
if (!onFocus || disabled) return;
|
||||
|
||||
const handleFocus = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
latestCallback.current();
|
||||
}
|
||||
};
|
||||
|
||||
// Revalidate when the page becomes visible
|
||||
document.addEventListener("visibilitychange", handleFocus);
|
||||
// Revalidate when the window gains focus
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleFocus);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [onFocus, disabled]);
|
||||
|
||||
// On load
|
||||
useEffect(() => {
|
||||
if (disabled || !onLoad) return;
|
||||
latestCallback.current();
|
||||
}, [disabled, onLoad]);
|
||||
}
|
||||
@@ -61,3 +61,29 @@ export function useIsImpersonating(matches?: UIMatch[]) {
|
||||
});
|
||||
return data?.isImpersonating === true;
|
||||
}
|
||||
|
||||
export type CustomDashboard = UseDataFunctionReturn<typeof orgLoader>["customDashboards"][number];
|
||||
|
||||
export function useCustomDashboards(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.customDashboards ?? [];
|
||||
}
|
||||
|
||||
export function useDashboardLimits(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.dashboardLimits ?? { used: 0, limit: 3 };
|
||||
}
|
||||
|
||||
export function useWidgetLimitPerDashboard(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.widgetLimitPerDashboard ?? 16;
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
+7
-3
@@ -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>
|
||||
|
||||
+17
-7
@@ -511,6 +511,11 @@ function QuotasSection({
|
||||
if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize);
|
||||
if (quotas.deployedQueueSize.limit !== null) quotaRows.push(quotas.deployedQueueSize);
|
||||
|
||||
// Metric & query quotas
|
||||
if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards);
|
||||
if (quotas.metricWidgetsPerDashboard) quotaRows.push(quotas.metricWidgetsPerDashboard);
|
||||
if (quotas.queryPeriodDays) quotaRows.push(quotas.queryPeriodDays);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Header2 className="flex items-center gap-1">
|
||||
@@ -555,13 +560,16 @@ function QuotaRow({
|
||||
isOnTopPlan: boolean;
|
||||
billingPath: string;
|
||||
}) {
|
||||
// For log retention, we don't show current usage as it's a duration, not a count
|
||||
const isRetentionQuota = quota.name === "Log retention";
|
||||
// For log retention and query period, we don't show current usage as it's a duration, not a count
|
||||
// For widgets per dashboard, the usage varies per dashboard so we don't show a single number
|
||||
const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period";
|
||||
const isPerItemQuota = quota.name === "Charts per dashboard";
|
||||
const isRetentionQuota = isDurationQuota || isPerItemQuota;
|
||||
const percentage =
|
||||
!isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
|
||||
|
||||
// Special handling for Log retention
|
||||
if (quota.name === "Log retention") {
|
||||
// Special handling for duration-based quotas (Log retention, Query period)
|
||||
if (isDurationQuota) {
|
||||
const canUpgrade = !isOnTopPlan;
|
||||
return (
|
||||
<TableRow>
|
||||
@@ -570,7 +578,9 @@ function QuotaRow({
|
||||
<InfoIconTooltip content={quota.description} disableHoverableContent />
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="font-medium tabular-nums">
|
||||
{quota.limit !== null ? `${formatNumber(quota.limit)} days` : "Unlimited"}
|
||||
{quota.limit !== null
|
||||
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
|
||||
: "Unlimited"}
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums text-text-dimmed">
|
||||
–
|
||||
@@ -648,8 +658,8 @@ function QuotaRow({
|
||||
</TableCell>
|
||||
<TableCell alignment="right" className="font-medium tabular-nums">
|
||||
{quota.limit !== null
|
||||
? isRetentionQuota
|
||||
? `${formatNumber(quota.limit)} days`
|
||||
? isDurationQuota
|
||||
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
|
||||
: formatNumber(quota.limit)
|
||||
: "Unlimited"}
|
||||
</TableCell>
|
||||
|
||||
+11
-47
@@ -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
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getAllTaskIdentifiers } from "~/models/task.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import {
|
||||
type LayoutItem,
|
||||
type Widget,
|
||||
MetricDashboardPresenter,
|
||||
} from "~/presenters/v3/MetricDashboardPresenter.server";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { z } from "zod";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import ReactGridLayout from "react-grid-layout";
|
||||
import { MetricWidget } from "../resources.metric";
|
||||
import { TitleWidget } from "~/components/metrics/TitleWidget";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
|
||||
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
|
||||
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
|
||||
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type WidgetData } from "~/components/metrics/QueryWidget";
|
||||
import { QueryScopeSchema } from "~/v3/querySchemas";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardKey: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam, dashboardKey } = ParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new MetricDashboardPresenter();
|
||||
const [dashboard, possibleTasks] = await Promise.all([
|
||||
presenter.builtInDashboard({
|
||||
organizationId: project.organizationId,
|
||||
key: dashboardKey,
|
||||
}),
|
||||
getAllTaskIdentifiers($replica, environment.id),
|
||||
]);
|
||||
|
||||
return typedjson({
|
||||
...dashboard,
|
||||
possibleTasks: possibleTasks
|
||||
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug)),
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
key,
|
||||
title,
|
||||
layout: dashboardLayout,
|
||||
defaultPeriod,
|
||||
possibleTasks,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={title} />
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="h-full">
|
||||
<MetricDashboard
|
||||
key={key}
|
||||
layout={dashboardLayout.layout}
|
||||
widgets={dashboardLayout.widgets}
|
||||
defaultPeriod={defaultPeriod}
|
||||
editable={false}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricDashboard({
|
||||
layout,
|
||||
widgets,
|
||||
defaultPeriod,
|
||||
editable,
|
||||
possibleTasks,
|
||||
onLayoutChange,
|
||||
onEditWidget,
|
||||
onRenameWidget,
|
||||
onDeleteWidget,
|
||||
onDuplicateWidget,
|
||||
}: {
|
||||
/** The layout items (positions/sizes) - fully controlled from parent */
|
||||
layout: LayoutItem[];
|
||||
/** The widget configurations keyed by widget ID - fully controlled from parent */
|
||||
widgets: Record<string, Widget>;
|
||||
defaultPeriod: string;
|
||||
editable: boolean;
|
||||
/** Possible tasks for filtering */
|
||||
possibleTasks?: { slug: string; triggerSource: TaskTriggerSource }[];
|
||||
onLayoutChange?: (layout: LayoutItem[]) => void;
|
||||
onEditWidget?: (widgetId: string, widget: WidgetData) => void;
|
||||
onRenameWidget?: (widgetId: string, newTitle: string) => void;
|
||||
onDeleteWidget?: (widgetId: string) => void;
|
||||
onDuplicateWidget?: (widgetId: string, widget: WidgetData) => void;
|
||||
}) {
|
||||
const { value, values } = useSearchParams();
|
||||
const { width, containerRef, mounted } = useContainerWidth();
|
||||
const [resizingItemId, setResizingItemId] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const isInteracting = resizingItemId !== null || isDragging;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const plan = useCurrentPlan();
|
||||
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
|
||||
|
||||
const period = value("period");
|
||||
const from = value("from");
|
||||
const to = value("to");
|
||||
const parsedScope = QueryScopeSchema.safeParse(value("scope") ?? "environment");
|
||||
const scope = parsedScope.success ? parsedScope.data : "environment";
|
||||
const tasks = values("tasks").filter((v) => v !== "");
|
||||
const queues = values("queues").filter((v) => v !== "");
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(newLayout: readonly LayoutItem[]) => {
|
||||
const mutableLayout = [...newLayout];
|
||||
onLayoutChange?.(mutableLayout);
|
||||
},
|
||||
[onLayoutChange]
|
||||
);
|
||||
|
||||
// Apply constraints for title widgets: fixed height of 2, allow horizontal resize only
|
||||
const constrainedLayout = useMemo(
|
||||
() =>
|
||||
layout.map((item) => {
|
||||
const widget = widgets[item.i];
|
||||
if (widget?.display.type === "title") {
|
||||
return { ...item, h: 2, minH: 2, maxH: 2 };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
[layout, widgets]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-center gap-1 border-b border-b-grid-bright py-2 pl-2 pr-3">
|
||||
<ScopeFilter />
|
||||
<LogsTaskFilter possibleTasks={possibleTasks ?? []} />
|
||||
<QueuesFilter />
|
||||
<TimeFilter
|
||||
defaultPeriod={defaultPeriod}
|
||||
labelName="Period"
|
||||
hideLabel
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
valueClassName="text-text-bright"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"overflow-y-auto scrollbar-thin scrollbar-track-charcoal-800 scrollbar-thumb-charcoal-700",
|
||||
isInteracting && "select-none"
|
||||
)}
|
||||
>
|
||||
{mounted && (
|
||||
<ReactGridLayout
|
||||
layout={constrainedLayout}
|
||||
width={width}
|
||||
gridConfig={{ cols: 12, rowHeight: 30 }}
|
||||
resizeConfig={{
|
||||
enabled: editable,
|
||||
handles: ["se"],
|
||||
}}
|
||||
dragConfig={{ enabled: editable, handle: ".drag-handle" }}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
onResizeStart={(_layout, oldItem) => setResizingItemId(oldItem?.i ?? null)}
|
||||
onResizeStop={() => setResizingItemId(null)}
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragStop={() => setIsDragging(false)}
|
||||
>
|
||||
{Object.entries(widgets).map(([key, widget]) => (
|
||||
<div key={key}>
|
||||
{widget.display.type === "title" ? (
|
||||
<TitleWidget
|
||||
title={widget.title}
|
||||
isDraggable={editable}
|
||||
isResizing={resizingItemId === key}
|
||||
onRename={
|
||||
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
|
||||
}
|
||||
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<MetricWidget
|
||||
widgetKey={key}
|
||||
title={widget.title}
|
||||
query={widget.query}
|
||||
scope={scope}
|
||||
period={period ?? 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 };
|
||||
}
|
||||
+772
@@ -0,0 +1,772 @@
|
||||
import { ArrowUpCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { IconChartHistogram, IconEdit, IconTypography } from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { defaultChartConfig } from "~/components/code/ChartConfigPanel";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
|
||||
import { ToastUI } from "~/components/primitives/Toast";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { useDashboardEditor } from "~/hooks/useDashboardEditor";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization, useWidgetLimitPerDashboard } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getAllTaskIdentifiers } from "~/models/task.server";
|
||||
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
|
||||
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
queryPath,
|
||||
v3BillingPath,
|
||||
v3BuiltInDashboardPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { Type } from "lucide-react";
|
||||
|
||||
const ParamSchema = EnvironmentParamSchema.extend({
|
||||
dashboardId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const dashboardPresenter = new MetricDashboardPresenter();
|
||||
const queryPresenter = new QueryPresenter();
|
||||
|
||||
const [dashboard, { defaultQuery, history }, possibleTasks] = await Promise.all([
|
||||
dashboardPresenter.customDashboard({
|
||||
friendlyId: dashboardId,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
queryPresenter.call({
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
getAllTaskIdentifiers($replica, environment.id),
|
||||
]);
|
||||
|
||||
// Admins and impersonating users can use EXPLAIN
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
|
||||
// Compute widget count from dashboard layout
|
||||
const widgetCount = Object.keys(dashboard.layout.widgets).length;
|
||||
|
||||
return typedjson({
|
||||
...dashboard,
|
||||
// Query editor data
|
||||
queryDefaultQuery: defaultQuery,
|
||||
queryHistory: history,
|
||||
isAdmin,
|
||||
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
|
||||
possibleTasks: possibleTasks
|
||||
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug)),
|
||||
widgetCount,
|
||||
});
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Load the dashboard
|
||||
const dashboard = await prisma.metricsDashboard.findFirst({
|
||||
where: {
|
||||
friendlyId: dashboardId,
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new Response("Dashboard not found", { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action");
|
||||
|
||||
switch (action) {
|
||||
case "delete": {
|
||||
await prisma.metricsDashboard.delete({
|
||||
where: { id: dashboard.id },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3BuiltInDashboardPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
"overview"
|
||||
),
|
||||
request,
|
||||
`Deleted "${dashboard.title}" dashboard`
|
||||
);
|
||||
}
|
||||
case "rename": {
|
||||
const newTitle = formData.get("title");
|
||||
if (typeof newTitle !== "string" || newTitle.trim().length === 0) {
|
||||
throw new Response("Title is required", { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.metricsDashboard.update({
|
||||
where: { id: dashboard.id },
|
||||
data: { title: newTitle.trim() },
|
||||
});
|
||||
|
||||
return typedjson({ success: true });
|
||||
}
|
||||
default: {
|
||||
throw new Response("Invalid action", { status: 400 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
friendlyId,
|
||||
title,
|
||||
layout: dashboardLayout,
|
||||
defaultPeriod,
|
||||
queryDefaultQuery,
|
||||
queryHistory,
|
||||
isAdmin,
|
||||
maxRows,
|
||||
possibleTasks,
|
||||
widgetCount: initialWidgetCount,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const plan = useCurrentPlan();
|
||||
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
|
||||
|
||||
// Widget limits
|
||||
const widgetLimitPerDashboard = useWidgetLimitPerDashboard();
|
||||
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricWidgetsPerDashboard;
|
||||
const canExceedWidgets = typeof planLimits === "object" && planLimits.canExceed === true;
|
||||
|
||||
// Build the action URLs - both use the resource route to avoid full page renders on POST
|
||||
const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`;
|
||||
const layoutActionUrl = widgetActionUrl;
|
||||
|
||||
// Handle sync errors by showing a toast
|
||||
const handleSyncError = useCallback((error: Error, action: string) => {
|
||||
const actionMessages: Record<string, string> = {
|
||||
add: "Failed to add widget",
|
||||
update: "Failed to update widget",
|
||||
delete: "Failed to delete widget",
|
||||
duplicate: "Failed to duplicate widget",
|
||||
layout: "Failed to save layout",
|
||||
};
|
||||
|
||||
const message = actionMessages[action] || "Failed to save changes";
|
||||
|
||||
toast.custom((t) => (
|
||||
<ToastUI
|
||||
variant="error"
|
||||
message={`${message}. Your changes may not be saved.`}
|
||||
t={t as string}
|
||||
title="Sync Error"
|
||||
/>
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Add title dialog state
|
||||
const [showAddTitleDialog, setShowAddTitleDialog] = useState(false);
|
||||
const [newTitleValue, setNewTitleValue] = useState("");
|
||||
|
||||
// Widget limit dialog state (triggered when hook blocks add/duplicate)
|
||||
const [showWidgetLimitDialog, setShowWidgetLimitDialog] = useState(false);
|
||||
|
||||
const handleWidgetLimitReached = useCallback(() => {
|
||||
setShowWidgetLimitDialog(true);
|
||||
}, []);
|
||||
|
||||
// Use the dashboard editor hook for all state management
|
||||
const { state, actions } = useDashboardEditor({
|
||||
initialData: dashboardLayout,
|
||||
widgetActionUrl,
|
||||
layoutActionUrl,
|
||||
widgetLimit: canExceedWidgets ? undefined : widgetLimitPerDashboard,
|
||||
onSyncError: handleSyncError,
|
||||
onWidgetLimitReached: handleWidgetLimitReached,
|
||||
});
|
||||
|
||||
// Reactive widget count from editor state (title widgets don't count against limits)
|
||||
const currentWidgetCount = Object.values(state.widgets).filter(
|
||||
(w) => w.display.type !== "title"
|
||||
).length;
|
||||
const totalWidgetCount = Object.keys(state.widgets).length;
|
||||
const widgetLimits = { used: currentWidgetCount, limit: widgetLimitPerDashboard };
|
||||
const widgetIsAtLimit = currentWidgetCount >= widgetLimitPerDashboard;
|
||||
const widgetLimitRatio =
|
||||
widgetLimits.limit > 0 ? widgetLimits.used / widgetLimits.limit : widgetLimits.used > 0 ? 1 : 0;
|
||||
const widgetLimitPercent = Math.min(100, Math.max(0, Math.round(widgetLimitRatio * 100)));
|
||||
const widgetCanUpgrade = plan?.v3Subscription?.plan && !canExceedWidgets;
|
||||
|
||||
// Build the query action URL for the editor
|
||||
const queryActionUrl = queryPath(
|
||||
{ slug: organization.slug },
|
||||
{ slug: project.slug },
|
||||
{ slug: environment.slug }
|
||||
);
|
||||
|
||||
// Handle save from the QueryEditor
|
||||
const handleSave = useCallback(
|
||||
(data: QueryEditorSaveData) => {
|
||||
if (state.editorMode?.type === "add") {
|
||||
actions.addWidget(data.title, data.query, data.config);
|
||||
} else if (state.editorMode?.type === "edit") {
|
||||
actions.updateWidget(state.editorMode.widgetId, data.title, data.query, data.config);
|
||||
}
|
||||
},
|
||||
[state.editorMode, actions]
|
||||
);
|
||||
|
||||
// Render save button for the QueryEditor
|
||||
const renderSaveForm = useCallback(
|
||||
(data: QueryEditorSaveData) => {
|
||||
const isAdd = state.editorMode?.type === "add";
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary/small"
|
||||
disabled={!data.query}
|
||||
onClick={() => handleSave(data)}
|
||||
>
|
||||
{isAdd ? "Add to dashboard" : "Save changes"}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[state.editorMode, handleSave]
|
||||
);
|
||||
|
||||
// Prepare editor props when in editor mode
|
||||
const editorProps = state.editorMode
|
||||
? (() => {
|
||||
const mode =
|
||||
state.editorMode.type === "add"
|
||||
? { type: "dashboard-add" as const, dashboardId: friendlyId, dashboardName: title }
|
||||
: {
|
||||
type: "dashboard-edit" as const,
|
||||
dashboardId: friendlyId,
|
||||
dashboardName: title,
|
||||
widgetId: state.editorMode.widgetId,
|
||||
widgetName: state.editorMode.widget.title,
|
||||
};
|
||||
|
||||
// For edit mode, use the widget's existing values as defaults
|
||||
const editorDefaultQuery =
|
||||
state.editorMode.type === "edit" ? state.editorMode.widget.query : queryDefaultQuery;
|
||||
const editorDefaultChartConfig =
|
||||
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "chart"
|
||||
? {
|
||||
chartType: state.editorMode.widget.display.chartType,
|
||||
xAxisColumn: state.editorMode.widget.display.xAxisColumn,
|
||||
yAxisColumns: state.editorMode.widget.display.yAxisColumns,
|
||||
groupByColumn: state.editorMode.widget.display.groupByColumn,
|
||||
stacked: state.editorMode.widget.display.stacked,
|
||||
sortByColumn: state.editorMode.widget.display.sortByColumn,
|
||||
sortDirection: state.editorMode.widget.display.sortDirection,
|
||||
aggregation: state.editorMode.widget.display.aggregation,
|
||||
seriesColors: state.editorMode.widget.display.seriesColors,
|
||||
}
|
||||
: defaultChartConfig;
|
||||
const editorDefaultBigNumberConfig =
|
||||
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "bignumber"
|
||||
? {
|
||||
column: state.editorMode.widget.display.column,
|
||||
aggregation: state.editorMode.widget.display.aggregation,
|
||||
sortDirection: state.editorMode.widget.display.sortDirection,
|
||||
abbreviate: state.editorMode.widget.display.abbreviate,
|
||||
prefix: state.editorMode.widget.display.prefix,
|
||||
suffix: state.editorMode.widget.display.suffix,
|
||||
}
|
||||
: undefined;
|
||||
const editorDefaultResultsView =
|
||||
state.editorMode.type === "edit" ? state.editorMode.widget.display.type : "table";
|
||||
// Pass the existing result data when editing
|
||||
const editorDefaultData =
|
||||
state.editorMode.type === "edit" ? state.editorMode.widget.resultData : undefined;
|
||||
|
||||
return {
|
||||
mode,
|
||||
editorDefaultQuery,
|
||||
editorDefaultChartConfig,
|
||||
editorDefaultBigNumberConfig,
|
||||
editorDefaultResultsView,
|
||||
editorDefaultData,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={title} />
|
||||
<PageAccessories>
|
||||
{totalWidgetCount > 0 &&
|
||||
(widgetIsAtLimit ? (
|
||||
<>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary/small" LeadingIcon={PlusIcon}>
|
||||
Add chart
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>You've exceeded your widget limit</DialogHeader>
|
||||
<DialogDescription>
|
||||
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this
|
||||
dashboard.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
{widgetCanUpgrade ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="primary/small">Request more</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={Type}
|
||||
className="pl-1.5"
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => {
|
||||
setNewTitleValue("");
|
||||
setShowAddTitleDialog(true);
|
||||
}}
|
||||
>
|
||||
Add title
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={IconChartHistogram}
|
||||
className="pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
onClick={actions.openAddEditor}
|
||||
>
|
||||
Add chart
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={Type}
|
||||
className="pl-1.5"
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => {
|
||||
setNewTitleValue("");
|
||||
setShowAddTitleDialog(true);
|
||||
}}
|
||||
>
|
||||
Add title
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
<Popover>
|
||||
<PopoverVerticalEllipseTrigger variant="secondary" />
|
||||
<PopoverContent className="w-fit min-w-[10rem] p-1" align="end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<RenameDashboardDialog title={title} />
|
||||
<DeleteDashboardDialog title={title} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
{totalWidgetCount === 0 ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<InfoPanel
|
||||
icon={IconChartHistogram}
|
||||
iconClassName="text-metrics"
|
||||
panelClassName="max-full"
|
||||
title="Add your first chart"
|
||||
accessory={
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
onClick={actions.openAddEditor}
|
||||
>
|
||||
Add chart
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
Charts let you visualize your task metrics. Write a query to pull data from your
|
||||
runs, then choose how to display it on this dashboard.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MetricDashboard
|
||||
key={friendlyId}
|
||||
layout={state.layout}
|
||||
widgets={state.widgets}
|
||||
defaultPeriod={defaultPeriod}
|
||||
editable={true}
|
||||
possibleTasks={possibleTasks}
|
||||
onLayoutChange={actions.updateLayout}
|
||||
onEditWidget={actions.openEditEditor}
|
||||
onRenameWidget={actions.renameWidget}
|
||||
onDeleteWidget={actions.deleteWidget}
|
||||
onDuplicateWidget={actions.duplicateWidget}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${
|
||||
widgetIsAtLimit ? "stroke-error" : "stroke-success"
|
||||
}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${widgetLimitRatio * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${widgetLimitPercent}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{widgetIsAtLimit ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {widgetLimits.limit} of your available widgets. Upgrade your
|
||||
plan to enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<Header3>
|
||||
You've used {widgetLimits.used}/{widgetLimits.limit} of your charts
|
||||
</Header3>
|
||||
)}
|
||||
{widgetCanUpgrade ? (
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Request more…</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
{/* Query Editor Sheet - opens on top of the dashboard */}
|
||||
<Sheet open={!!state.editorMode} onOpenChange={(open) => !open && actions.closeEditor()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[90vw] max-w-none rounded-l-lg border-l border-grid-dimmed p-0 sm:max-w-none"
|
||||
>
|
||||
{editorProps && (
|
||||
<QueryEditor
|
||||
defaultQuery={editorProps.editorDefaultQuery}
|
||||
defaultScope="environment"
|
||||
defaultPeriod={defaultPeriod}
|
||||
defaultResultsView={
|
||||
editorProps.editorDefaultResultsView === "chart"
|
||||
? "graph"
|
||||
: editorProps.editorDefaultResultsView === "bignumber"
|
||||
? "bignumber"
|
||||
: "table"
|
||||
}
|
||||
defaultChartConfig={editorProps.editorDefaultChartConfig}
|
||||
defaultBigNumberConfig={editorProps.editorDefaultBigNumberConfig}
|
||||
defaultData={editorProps.editorDefaultData}
|
||||
history={queryHistory}
|
||||
isAdmin={isAdmin}
|
||||
maxRows={maxRows}
|
||||
queryActionUrl={queryActionUrl}
|
||||
mode={editorProps.mode}
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
save={renderSaveForm}
|
||||
onClose={actions.closeEditor}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Add title dialog */}
|
||||
<Dialog open={showAddTitleDialog} onOpenChange={setShowAddTitleDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Add title</DialogHeader>
|
||||
<form
|
||||
className="space-y-4 pt-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newTitleValue.trim()) {
|
||||
actions.addTitleWidget(newTitleValue.trim());
|
||||
setShowAddTitleDialog(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={newTitleValue}
|
||||
onChange={(e) => setNewTitleValue(e.target.value)}
|
||||
placeholder="Section title"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary/medium" disabled={!newTitleValue.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Widget limit dialog - triggered by hook when add/duplicate is blocked */}
|
||||
<Dialog open={showWidgetLimitDialog} onOpenChange={setShowWidgetLimitDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>You've exceeded your widget limit</DialogHeader>
|
||||
<DialogDescription>
|
||||
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
{widgetCanUpgrade ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="primary/small">Request more</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameDashboardDialog({ title }: { title: string }) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState(title);
|
||||
|
||||
const isRenaming =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "rename";
|
||||
|
||||
// Close dialog when navigation completes
|
||||
useEffect(() => {
|
||||
if (navigation.state === "idle") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.state]);
|
||||
|
||||
// Sync newTitle state when title changes (after successful rename)
|
||||
useEffect(() => {
|
||||
setNewTitle(title);
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={IconEdit}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="pl-0.5 pr-3"
|
||||
leadingIconClassName="gap-x-0"
|
||||
>
|
||||
Rename dashboard
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Rename dashboard</DialogHeader>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="action" value="rename" />
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
name="title"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Dashboard title"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={isRenaming || !newTitle.trim()}
|
||||
>
|
||||
{isRenaming ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteDashboardDialog({ title }: { title: string }) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const isDeleting =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
// Close dialog when navigation completes
|
||||
useEffect(() => {
|
||||
if (navigation.state === "idle") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.state]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Delete dashboard
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Delete dashboard</DialogHeader>
|
||||
<div className="mb-2 mt-4 flex flex-col gap-2">
|
||||
<Paragraph>
|
||||
Are you sure you want to delete <strong>"{title}"</strong>? This action cannot be undone
|
||||
and all widgets on this dashboard will be permanently removed.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="delete"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+11
-5
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { AIQueryInput } from "~/components/code/AIQueryInput";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import type { AITimeFilter } from "./types";
|
||||
|
||||
export function AITabContent({
|
||||
@@ -28,6 +30,8 @@ export function AITabContent({
|
||||
"Top 50 most expensive runs this week",
|
||||
"Average execution duration by task this week",
|
||||
"Run counts by tag in the past 7 days",
|
||||
"CPU utilization over time by task",
|
||||
"Peak memory usage per run",
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -41,8 +45,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 +57,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 +70,3 @@ export function AITabContent({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+79
-1
@@ -1,6 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
import { TryableCodeBlock } from "./TRQLGuideContent";
|
||||
|
||||
// Example queries for the Examples tab
|
||||
@@ -9,6 +12,7 @@ export const exampleQueries: Array<{
|
||||
description: string;
|
||||
query: string;
|
||||
scope: QueryScope;
|
||||
table: string;
|
||||
}> = [
|
||||
{
|
||||
title: "Failed runs by task (past 7 days)",
|
||||
@@ -23,6 +27,7 @@ GROUP BY task_identifier
|
||||
ORDER BY failed_count DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "Execution duration p50 by task (past 7d)",
|
||||
@@ -37,6 +42,21 @@ GROUP BY task_identifier
|
||||
ORDER BY p50_duration_ms DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
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",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "Most expensive 100 runs (past 7d)",
|
||||
@@ -54,17 +74,75 @@ WHERE triggered_at > now() - INTERVAL 7 DAY
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT 100`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "CPU utilization over time",
|
||||
description: "Track process CPU utilization bucketed over time.",
|
||||
query: `SELECT
|
||||
timeBucket(),
|
||||
avg(metric_value) AS avg_cpu
|
||||
FROM metrics
|
||||
WHERE metric_name = 'process.cpu.utilization'
|
||||
GROUP BY timeBucket
|
||||
ORDER BY timeBucket
|
||||
LIMIT 1000`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
{
|
||||
title: "Memory usage by task (past 7d)",
|
||||
description: "Average memory usage per task identifier over the last 7 days.",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
avg(metric_value) AS avg_memory
|
||||
FROM metrics
|
||||
WHERE metric_name = 'system.memory.usage'
|
||||
AND bucket_start > now() - INTERVAL 7 DAY
|
||||
GROUP BY task_identifier
|
||||
ORDER BY avg_memory DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
{
|
||||
title: "Available metric names",
|
||||
description: "List all distinct metric names collected in your environment.",
|
||||
query: `SELECT
|
||||
metric_name,
|
||||
count() AS sample_count
|
||||
FROM metrics
|
||||
GROUP BY metric_name
|
||||
ORDER BY sample_count DESC
|
||||
LIMIT 100`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
];
|
||||
|
||||
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
|
||||
|
||||
export function ExamplesContent({
|
||||
onTryExample,
|
||||
}: {
|
||||
onTryExample: (query: string, scope: QueryScope) => void;
|
||||
}) {
|
||||
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
|
||||
const filtered = exampleQueries.filter((e) => e.table === selectedTable);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{exampleQueries.map((example) => (
|
||||
<div className="sticky top-0 z-10 bg-background-bright pb-3">
|
||||
<SegmentedControl
|
||||
name="examples-table-selector"
|
||||
value={selectedTable}
|
||||
options={tableOptions}
|
||||
variant="secondary/small"
|
||||
fullWidth
|
||||
onChange={setSelectedTable}
|
||||
/>
|
||||
</div>
|
||||
{filtered.map((example) => (
|
||||
<div key={example.title}>
|
||||
<Header3 className="mb-1 text-text-bright">{example.title}</Header3>
|
||||
<Paragraph variant="small" className="mb-2 text-text-dimmed">
|
||||
|
||||
+31
-20
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { Popover, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
|
||||
import { timeFilterRenderValues } from "~/components/runs/v3/SharedFilters";
|
||||
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
@@ -91,23 +94,32 @@ export function QueryHistoryPopover({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ClockRotateLeftIcon}
|
||||
leadingIconClassName="-mr-1.5"
|
||||
TrailingIcon={ChevronUpDownIcon}
|
||||
disabled={history.length === 0}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] min-w-0 overflow-hidden p-0"
|
||||
<PopoverPrimitive.Content
|
||||
className={cn(
|
||||
"z-50 w-[400px] min-w-0 overflow-hidden rounded border border-charcoal-700 bg-background-bright p-0 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
)}
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="max-h-[40rem] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="p-1">
|
||||
{history.map((item) => {
|
||||
// Format time filter display
|
||||
const { valueLabel } = timeFilterRenderValues({ period: item.filterPeriod ?? undefined, from: item.filterFrom ?? undefined, to: item.filterTo ?? undefined });
|
||||
const { valueLabel } = timeFilterRenderValues({
|
||||
period: item.filterPeriod ?? undefined,
|
||||
from: item.filterFrom ?? undefined,
|
||||
to: item.filterTo ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -117,21 +129,16 @@ export function QueryHistoryPopover({
|
||||
onQuerySelected(item);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900"
|
||||
className="flex w-full flex-col gap-1 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-750"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-start gap-0.5 overflow-hidden">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
{item.title ? (
|
||||
<>
|
||||
<p className="w-full truncate text-left text-sm font-medium text-text-bright">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
</>
|
||||
<p className="mb-1 truncate text-left text-sm font-medium text-text-bright">
|
||||
{item.title}
|
||||
</p>
|
||||
) : (
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-[#9b99ff]">
|
||||
{highlightSQL(item.query)}
|
||||
<p className="mb-1 truncate text-left font-mono text-xs text-text-bright">
|
||||
{item.query.split("\n")[0].slice(0, 60)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
|
||||
@@ -140,13 +147,17 @@ export function QueryHistoryPopover({
|
||||
{item.userName && <span>· {item.userName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full border-l-2 border-charcoal-600 pl-2.5">
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPrimitive.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -488,6 +488,11 @@ ORDER BY run_count DESC`,
|
||||
<FunctionCategory
|
||||
title="Date/time functions"
|
||||
functions={[
|
||||
{
|
||||
name: "timeBucket()",
|
||||
desc: "Auto-bucket by time period. Uses the table's time column with an interval based on the query's time range.",
|
||||
example: "SELECT timeBucket(), count() FROM runs GROUP BY timeBucket",
|
||||
},
|
||||
{ name: "now()", desc: "Current date and time", example: "now()" },
|
||||
{ name: "today()", desc: "Current date", example: "today()" },
|
||||
{ name: "yesterday()", desc: "Yesterday's date", example: "yesterday()" },
|
||||
|
||||
+29
-17
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import type { ColumnSchema } from "@internal/tsql";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
|
||||
function ColumnHelpItem({ col }: { col: ColumnSchema }) {
|
||||
@@ -42,26 +44,36 @@ function ColumnHelpItem({ col }: { col: ColumnSchema }) {
|
||||
);
|
||||
}
|
||||
|
||||
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
|
||||
|
||||
export function TableSchemaContent() {
|
||||
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
|
||||
const table = querySchemas.find((s) => s.name === selectedTable) ?? querySchemas[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{querySchemas.map((table) => (
|
||||
<div key={table.name} className="mb-6">
|
||||
<div className="mb-2">
|
||||
<Header3 className="font-mono text-text-bright">{table.name}</Header3>
|
||||
{table.description && (
|
||||
<Paragraph variant="small" className="mt-1 text-text-dimmed">
|
||||
{table.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 divide-y divide-grid-dimmed">
|
||||
{Object.values(table.columns).map((col) => (
|
||||
<ColumnHelpItem key={col.name} col={col} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="sticky top-0 z-10 bg-background-bright pb-3">
|
||||
<SegmentedControl
|
||||
name="table-schema-selector"
|
||||
value={selectedTable}
|
||||
options={tableOptions}
|
||||
variant="secondary/small"
|
||||
fullWidth
|
||||
onChange={setSelectedTable}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
{table.description && (
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{table.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 divide-y divide-grid-dimmed">
|
||||
{Object.values(table.columns).map((col) => (
|
||||
<ColumnHelpItem key={col.name} col={col} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+45
-910
File diff suppressed because it is too large
Load Diff
+132
-14
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user