Merge branch 'main' into HEAD
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
---
|
||||
"@trigger.dev/sdk": minor
|
||||
---
|
||||
|
||||
Added `query.execute()` which lets you query your Trigger.dev data using TRQL (Trigger Query Language) and returns results as typed JSON rows or CSV. It supports configurable scope (environment, project, or organization), time filtering via `period` or `from`/`to` ranges, and a `format` option for JSON or CSV output.
|
||||
|
||||
```typescript
|
||||
import { query } from "@trigger.dev/sdk";
|
||||
import type { QueryTable } from "@trigger.dev/sdk";
|
||||
|
||||
// Basic untyped query
|
||||
const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10");
|
||||
|
||||
// Type-safe query using QueryTable to pick specific columns
|
||||
const typedResult = await query.execute<QueryTable<"runs", "run_id" | "status" | "triggered_at">>(
|
||||
"SELECT run_id, status, triggered_at FROM runs LIMIT 10"
|
||||
);
|
||||
typedResult.results.forEach(row => {
|
||||
console.log(row.run_id, row.status); // Fully typed
|
||||
});
|
||||
|
||||
// Aggregation query with inline types
|
||||
const stats = await query.execute<{ status: string; count: number }>(
|
||||
"SELECT status, COUNT(*) as count FROM runs GROUP BY status",
|
||||
{ scope: "project", period: "30d" }
|
||||
);
|
||||
|
||||
// CSV export
|
||||
const csv = await query.execute(
|
||||
"SELECT run_id, status FROM runs",
|
||||
{ format: "csv", period: "7d" }
|
||||
);
|
||||
console.log(csv.results); // Raw CSV string
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add optional `timeoutInSeconds` parameter to the `wait_for_run_to_complete` MCP tool. Defaults to 60 seconds. If the run doesn't complete within the timeout, the current state of the run is returned instead of waiting indefinitely.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fixed a minor issue in the deployment command on distinguishing between local builds for the cloud vs local builds for self-hosting setups.
|
||||
+4
-1
@@ -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,60 +1,26 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { BarChart3, LineChart } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import type { ChartConfig } from "~/components/primitives/charts/Chart";
|
||||
import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
|
||||
|
||||
// Color palette for chart series - 30 distinct colors for large datasets
|
||||
const CHART_COLORS = [
|
||||
// Primary colors
|
||||
"#7655fd", // Purple
|
||||
"#22c55e", // Green
|
||||
"#f59e0b", // Amber
|
||||
"#ef4444", // Red
|
||||
"#06b6d4", // Cyan
|
||||
"#ec4899", // Pink
|
||||
"#8b5cf6", // Violet
|
||||
"#14b8a6", // Teal
|
||||
"#f97316", // Orange
|
||||
"#6366f1", // Indigo
|
||||
// Extended palette
|
||||
"#84cc16", // Lime
|
||||
"#0ea5e9", // Sky
|
||||
"#f43f5e", // Rose
|
||||
"#a855f7", // Fuchsia
|
||||
"#eab308", // Yellow
|
||||
"#10b981", // Emerald
|
||||
"#3b82f6", // Blue
|
||||
"#d946ef", // Magenta
|
||||
"#78716c", // Stone
|
||||
"#facc15", // Gold
|
||||
// Additional distinct colors
|
||||
"#2dd4bf", // Turquoise
|
||||
"#fb923c", // Light orange
|
||||
"#a3e635", // Yellow-green
|
||||
"#38bdf8", // Light blue
|
||||
"#c084fc", // Light purple
|
||||
"#4ade80", // Light green
|
||||
"#fbbf24", // Light amber
|
||||
"#f472b6", // Light pink
|
||||
"#67e8f9", // Light cyan
|
||||
"#818cf8", // Light indigo
|
||||
];
|
||||
|
||||
function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
|
||||
import { aggregateValues } from "../primitives/charts/aggregation";
|
||||
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { getSeriesColor } from "./chartColors";
|
||||
|
||||
interface QueryResultsChartProps {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
/** The effective time range from the query filter (used to show the full x-axis period) */
|
||||
timeRange?: { from: string; to: string };
|
||||
fullLegend?: boolean;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
legendScrollable?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface TransformedData {
|
||||
@@ -153,12 +119,41 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a millisecond value up to the nearest "nice" interval
|
||||
*/
|
||||
function snapToNiceInterval(ms: number): number {
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
if (ms <= SECOND) return SECOND;
|
||||
if (ms <= 5 * SECOND) return 5 * SECOND;
|
||||
if (ms <= 10 * SECOND) return 10 * SECOND;
|
||||
if (ms <= 15 * SECOND) return 15 * SECOND;
|
||||
if (ms <= 30 * SECOND) return 30 * SECOND;
|
||||
if (ms <= MINUTE) return MINUTE;
|
||||
if (ms <= 5 * MINUTE) return 5 * MINUTE;
|
||||
if (ms <= 10 * MINUTE) return 10 * MINUTE;
|
||||
if (ms <= 15 * MINUTE) return 15 * MINUTE;
|
||||
if (ms <= 30 * MINUTE) return 30 * MINUTE;
|
||||
if (ms <= HOUR) return HOUR;
|
||||
if (ms <= 2 * HOUR) return 2 * HOUR;
|
||||
if (ms <= 4 * HOUR) return 4 * HOUR;
|
||||
if (ms <= 6 * HOUR) return 6 * HOUR;
|
||||
if (ms <= 12 * HOUR) return 12 * HOUR;
|
||||
if (ms <= DAY) return DAY;
|
||||
|
||||
return ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the most common interval between consecutive data points
|
||||
* This helps us understand the natural granularity of the data
|
||||
*/
|
||||
function detectDataInterval(timestamps: number[]): number {
|
||||
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
|
||||
if (timestamps.length < 2) return 24 * 60 * 60 * 1000; // Default to 1 day
|
||||
|
||||
const sorted = [...timestamps].sort((a, b) => a - b);
|
||||
const gaps: number[] = [];
|
||||
@@ -176,25 +171,7 @@ function detectDataInterval(timestamps: number[]): number {
|
||||
// We use the minimum gap as a heuristic for the data interval
|
||||
const minGap = Math.min(...gaps);
|
||||
|
||||
// Round to a nice interval
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
// Snap to common intervals
|
||||
if (minGap <= MINUTE) return MINUTE;
|
||||
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
|
||||
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
|
||||
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
|
||||
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
|
||||
if (minGap <= HOUR) return HOUR;
|
||||
if (minGap <= 2 * HOUR) return 2 * HOUR;
|
||||
if (minGap <= 4 * HOUR) return 4 * HOUR;
|
||||
if (minGap <= 6 * HOUR) return 6 * HOUR;
|
||||
if (minGap <= 12 * HOUR) return 12 * HOUR;
|
||||
if (minGap <= DAY) return DAY;
|
||||
|
||||
return minGap;
|
||||
return snapToNiceInterval(minGap);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,20 +195,7 @@ function fillTimeGaps(
|
||||
// If filling would create too many points, increase the interval to stay within limits
|
||||
let effectiveInterval = interval;
|
||||
if (estimatedPoints > maxPoints) {
|
||||
effectiveInterval = Math.ceil(range / maxPoints);
|
||||
// Round up to a nice interval
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
|
||||
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
|
||||
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
|
||||
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
|
||||
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
|
||||
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
|
||||
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
|
||||
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
|
||||
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
|
||||
else effectiveInterval = 24 * HOUR;
|
||||
effectiveInterval = snapToNiceInterval(Math.ceil(range / maxPoints));
|
||||
}
|
||||
|
||||
// Create a map to collect values for each bucket (for aggregation)
|
||||
@@ -281,17 +245,18 @@ function fillTimeGaps(
|
||||
}
|
||||
filledData.push(point);
|
||||
} else {
|
||||
// Create a zero-filled data point
|
||||
const zeroPoint: Record<string, unknown> = {
|
||||
// Create a null-filled data point so gaps appear in line/bar charts
|
||||
// and legend aggregations (avg/min/max) skip these slots
|
||||
const gapPoint: Record<string, unknown> = {
|
||||
[xDataKey]: t,
|
||||
__rawDate: new Date(t),
|
||||
__granularity: granularity,
|
||||
__originalX: new Date(t).toISOString(),
|
||||
};
|
||||
for (const s of series) {
|
||||
zeroPoint[s] = 0;
|
||||
gapPoint[s] = null;
|
||||
}
|
||||
filledData.push(zeroPoint);
|
||||
filledData.push(gapPoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,22 +355,32 @@ function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): numb
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for tooltips (always shows full precision)
|
||||
* Formats a date for tooltips and legend headers.
|
||||
* Always includes time when the data point has a non-midnight time,
|
||||
* so hovering a specific bar at e.g. 14:00 shows the full timestamp
|
||||
* even when the axis labels only show the day.
|
||||
* Seconds are shown whenever the granularity is "seconds" or the
|
||||
* specific data point has non-zero seconds.
|
||||
*/
|
||||
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
|
||||
// For shorter time ranges, include time
|
||||
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
|
||||
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0;
|
||||
const hasSeconds = date.getSeconds() !== 0;
|
||||
|
||||
if (
|
||||
granularity === "seconds" ||
|
||||
(hasTime && granularity !== "months" && granularity !== "years")
|
||||
) {
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: granularity === "seconds" ? "2-digit" : undefined,
|
||||
second: granularity === "seconds" || hasSeconds ? "2-digit" : undefined,
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
// For longer ranges, just show date
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -463,7 +438,8 @@ function tryParseDate(value: unknown): Date | null {
|
||||
*/
|
||||
function transformDataForChart(
|
||||
rows: Record<string, unknown>[],
|
||||
config: ChartConfiguration
|
||||
config: ChartConfiguration,
|
||||
timeRange?: { from: string; to: string }
|
||||
): TransformedData {
|
||||
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
|
||||
|
||||
@@ -489,24 +465,37 @@ function transformDataForChart(
|
||||
}
|
||||
|
||||
// Determine if X-axis is date-based (most values should be parseable as dates)
|
||||
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
|
||||
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
|
||||
// When there are no results but a timeRange is provided, treat as date-based
|
||||
const isDateBased =
|
||||
rows.length === 0 && timeRange ? true : dateValues.length >= rows.length * 0.8; // At least 80% are dates
|
||||
|
||||
// Detect granularity from the full time range when available, otherwise from data
|
||||
const granularity = isDateBased
|
||||
? timeRange
|
||||
? detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)])
|
||||
: detectTimeGranularity(dateValues)
|
||||
: "days";
|
||||
|
||||
// For date-based axes, use a special key for the timestamp
|
||||
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
|
||||
|
||||
// Calculate time domain and ticks for date-based axes
|
||||
// When a timeRange is provided (from the query filter), use it so the chart
|
||||
// shows the full requested period rather than just the range of returned data.
|
||||
let timeDomain: [number, number] | null = null;
|
||||
let timeTicks: number[] | null = null;
|
||||
if (isDateBased && dateValues.length > 0) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
// Raw min/max used for gap filling (without padding)
|
||||
let rawMinTime = 0;
|
||||
let rawMaxTime = 0;
|
||||
if (isDateBased && (dateValues.length > 0 || timeRange)) {
|
||||
const dataTimestamps = dateValues.map((d) => d.getTime());
|
||||
rawMinTime = timeRange ? new Date(timeRange.from).getTime() : Math.min(...dataTimestamps);
|
||||
rawMaxTime = timeRange ? new Date(timeRange.to).getTime() : Math.max(...dataTimestamps);
|
||||
// Add a small padding (2% on each side) so points aren't at the very edge
|
||||
const padding = (maxTime - minTime) * 0.02;
|
||||
timeDomain = [minTime - padding, maxTime + padding];
|
||||
const padding = (rawMaxTime - rawMinTime) * 0.02;
|
||||
timeDomain = [rawMinTime - padding, rawMaxTime + padding];
|
||||
// Generate evenly-spaced ticks across the entire range using nice intervals
|
||||
timeTicks = generateTimeTicks(minTime, maxTime);
|
||||
timeTicks = generateTimeTicks(rawMinTime, rawMaxTime);
|
||||
}
|
||||
|
||||
// Helper to format X value for categorical axes (non-date)
|
||||
@@ -564,13 +553,27 @@ function transformDataForChart(
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
// When filling across a full time range, ensure the interval is appropriate
|
||||
// for the range size (target ~150 points) so we don't create overly dense charts
|
||||
const rangeMs = rawMaxTime - rawMinTime;
|
||||
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / 150) : 0;
|
||||
// Also cap the interval so we get enough data points to visually represent
|
||||
// the full time range. Without this, limited data (e.g. 1 point) defaults
|
||||
// to a 1-day interval which can be far too coarse for shorter ranges,
|
||||
// producing too few bars/points and potentially buckets outside the domain.
|
||||
const maxRangeInterval =
|
||||
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
|
||||
const effectiveInterval = Math.min(
|
||||
Math.max(dataInterval, minRangeInterval),
|
||||
maxRangeInterval
|
||||
);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
yAxisColumns,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
rawMinTime,
|
||||
rawMaxTime,
|
||||
effectiveInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
);
|
||||
@@ -633,13 +636,27 @@ function transformDataForChart(
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
// When filling across a full time range, ensure the interval is appropriate
|
||||
// for the range size (target ~150 points) so we don't create overly dense charts
|
||||
const rangeMs = rawMaxTime - rawMinTime;
|
||||
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / 150) : 0;
|
||||
// Also cap the interval so we get enough data points to visually represent
|
||||
// the full time range. Without this, limited data (e.g. 1 point) defaults
|
||||
// to a 1-day interval which can be far too coarse for shorter ranges,
|
||||
// producing too few bars/points and potentially buckets outside the domain.
|
||||
const maxRangeInterval =
|
||||
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
|
||||
const effectiveInterval = Math.min(
|
||||
Math.max(dataInterval, minRangeInterval),
|
||||
maxRangeInterval
|
||||
);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
series,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
rawMinTime,
|
||||
rawMaxTime,
|
||||
effectiveInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
);
|
||||
@@ -657,25 +674,6 @@ function toNumber(value: unknown): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate an array of numbers using the specified aggregation function
|
||||
*/
|
||||
function aggregateValues(values: number[], aggregation: AggregationType): number {
|
||||
if (values.length === 0) return 0;
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
case "avg":
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
case "count":
|
||||
return values.length;
|
||||
case "min":
|
||||
return Math.min(...values);
|
||||
case "max":
|
||||
return Math.max(...values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort data array by a specified column
|
||||
*/
|
||||
@@ -725,8 +723,10 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
rows,
|
||||
columns,
|
||||
config,
|
||||
timeRange,
|
||||
fullLegend = false,
|
||||
onViewAllLegendItems,
|
||||
isLoading = false,
|
||||
legendScrollable = false,
|
||||
}: QueryResultsChartProps) {
|
||||
const {
|
||||
@@ -748,7 +748,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
|
||||
} = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]);
|
||||
|
||||
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
|
||||
const data = useMemo(() => {
|
||||
@@ -759,13 +759,37 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
|
||||
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
|
||||
|
||||
// Detect time granularity for the data
|
||||
const timeGranularity = useMemo(
|
||||
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
|
||||
[dateValues]
|
||||
);
|
||||
// Sort series by descending total sum so largest appears at bottom of
|
||||
// stacked charts and first in the legend
|
||||
const sortedSeries = useMemo(() => {
|
||||
if (series.length <= 1) return series;
|
||||
const totals = new Map<string, number>();
|
||||
for (const s of series) {
|
||||
let total = 0;
|
||||
for (const point of data) {
|
||||
const val = point[s];
|
||||
if (typeof val === "number" && isFinite(val)) {
|
||||
total += Math.abs(val);
|
||||
}
|
||||
}
|
||||
totals.set(s, total);
|
||||
}
|
||||
return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0));
|
||||
}, [series, data]);
|
||||
|
||||
// X-axis tick formatter for date-based axes
|
||||
// Detect time granularity — use the full time range when available so tick
|
||||
// labels are appropriate for the period (e.g. "Jan 5" for a 7-day range
|
||||
// instead of just "16:00:00" when data is sparse)
|
||||
const timeGranularity = useMemo(() => {
|
||||
if (timeRange) {
|
||||
return detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)]);
|
||||
}
|
||||
return dateValues.length > 0 ? detectTimeGranularity(dateValues) : null;
|
||||
}, [dateValues, timeRange]);
|
||||
|
||||
// X-axis tick formatter for date-based axes (pure – no deduplication).
|
||||
// Label deduplication is handled inside dateAxisTick below so that the
|
||||
// mutable "lastLabel" state is correctly reset on each Recharts render pass.
|
||||
const xAxisTickFormatter = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity) return undefined;
|
||||
return (value: number) => {
|
||||
@@ -777,17 +801,25 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
// Create dynamic Y-axis formatter based on data range
|
||||
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
|
||||
|
||||
// Check if the group-by column has a runStatus customRenderType
|
||||
const groupByIsRunStatus = useMemo(() => {
|
||||
if (!groupByColumn) return false;
|
||||
const col = columns.find((c) => c.name === groupByColumn);
|
||||
return col?.customRenderType === "runStatus";
|
||||
}, [groupByColumn, columns]);
|
||||
|
||||
// Build chart config for colors/labels
|
||||
const chartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
series.forEach((s, i) => {
|
||||
sortedSeries.forEach((s, i) => {
|
||||
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
|
||||
cfg[s] = {
|
||||
label: s,
|
||||
color: getSeriesColor(i),
|
||||
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(i),
|
||||
};
|
||||
});
|
||||
return cfg;
|
||||
}, [series]);
|
||||
}, [sortedSeries, groupByIsRunStatus, config.seriesColors]);
|
||||
|
||||
// Custom tooltip label formatter for better date display
|
||||
const tooltipLabelFormatter = useMemo(() => {
|
||||
@@ -831,30 +863,121 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
return [min, "auto"] as [number, string];
|
||||
}, [data, series]);
|
||||
|
||||
// Validation
|
||||
// Angle all date-based labels for consistent appearance and to avoid overlap
|
||||
const xAxisAngle = isDateBased ? -45 : 0;
|
||||
const xAxisHeight = xAxisAngle !== 0 ? 65 : undefined;
|
||||
|
||||
// Check if the data would produce duplicate labels at the current granularity.
|
||||
// Only use the custom tick renderer (with interval:0) when duplicates exist,
|
||||
// otherwise let Recharts handle label spacing to avoid collisions.
|
||||
const hasDuplicateLabels = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity || data.length === 0) return false;
|
||||
const labels = new Set<string>();
|
||||
for (const point of data) {
|
||||
const ts = point.__timestamp ?? point[xDataKey];
|
||||
if (typeof ts === "number") {
|
||||
labels.add(formatDateByGranularity(new Date(ts), timeGranularity));
|
||||
}
|
||||
}
|
||||
return labels.size < data.length;
|
||||
}, [isDateBased, timeGranularity, data, xDataKey]);
|
||||
|
||||
// Custom tick renderer for date-based axes: renders a tick mark alongside
|
||||
// each label, and for unlabelled points (de-duplicated) just a subtle tick mark.
|
||||
// De-duplication lives here (not in xAxisTickFormatter) so that the mutable
|
||||
// lastLabel is reset when Recharts starts a new render pass (index === 0).
|
||||
const dateAxisTick = useMemo(() => {
|
||||
if (!isDateBased || !xAxisTickFormatter) return undefined;
|
||||
let lastLabel = "";
|
||||
return (props: Record<string, unknown>) => {
|
||||
const { x, y, payload, index } = props as {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: number };
|
||||
index: number;
|
||||
};
|
||||
|
||||
// Reset dedup state at the start of each Recharts render pass
|
||||
if (index === 0) lastLabel = "";
|
||||
|
||||
const formatted = xAxisTickFormatter(payload.value);
|
||||
const label = formatted === lastLabel ? "" : formatted;
|
||||
lastLabel = formatted;
|
||||
// y is the tick text position, offset from the axis by tickMargin + internal padding
|
||||
const axisY = (y as number) - 12;
|
||||
if (label) {
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x as number}
|
||||
y1={axisY}
|
||||
x2={x as number}
|
||||
y2={axisY - 3}
|
||||
stroke="#878C99"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={axisY}
|
||||
dy={10}
|
||||
fill="#878C99"
|
||||
fontSize={11}
|
||||
textAnchor={xAxisAngle !== 0 ? "end" : "middle"}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
transform={
|
||||
xAxisAngle !== 0 ? `rotate(${xAxisAngle}, ${x}, ${axisY + 10})` : undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
// Small tick mark sitting on the axis baseline, pointing upward
|
||||
return (
|
||||
<line
|
||||
x1={x as number}
|
||||
y1={axisY}
|
||||
x2={x as number}
|
||||
y2={axisY - 3}
|
||||
stroke="#272A2E"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);
|
||||
|
||||
// Validation — all hooks must be above this point
|
||||
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
|
||||
|
||||
if (!xAxisColumn) {
|
||||
return <EmptyState message="Select an X-axis column to display the chart" />;
|
||||
return <ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />;
|
||||
}
|
||||
|
||||
if (yAxisColumns.length === 0) {
|
||||
return <EmptyState message="Select a Y-axis column to display the chart" />;
|
||||
return <ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState message="No data to display" />;
|
||||
return <ChartBlankState icon={chartIcon} message="No data to display" />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState message="Unable to transform data for chart" />;
|
||||
return <ChartBlankState icon={chartIcon} message="Unable to transform data for chart" />;
|
||||
}
|
||||
|
||||
// Determine appropriate angle for X-axis labels based on granularity
|
||||
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
|
||||
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
|
||||
|
||||
// Base x-axis props shared by all chart types
|
||||
const baseXAxisProps = {
|
||||
tickFormatter: xAxisTickFormatter,
|
||||
...(dateAxisTick
|
||||
? {
|
||||
tick: dateAxisTick,
|
||||
tickLine: false,
|
||||
tickFormatter: undefined,
|
||||
// Only force every tick to render when there are duplicates to de-duplicate;
|
||||
// otherwise let Recharts auto-space to avoid label collisions
|
||||
...(hasDuplicateLabels ? { interval: 0 } : {}),
|
||||
}
|
||||
: { tickFormatter: xAxisTickFormatter }),
|
||||
angle: xAxisAngle,
|
||||
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
|
||||
height: xAxisHeight,
|
||||
@@ -864,13 +987,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
// This properly represents time gaps between data points
|
||||
const xAxisPropsForLine = isDateBased
|
||||
? {
|
||||
type: "number" as const,
|
||||
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
|
||||
scale: "time" as const,
|
||||
// Explicitly specify tick positions so labels appear across the entire range
|
||||
ticks: timeTicks ?? undefined,
|
||||
...baseXAxisProps,
|
||||
}
|
||||
type: "number" as const,
|
||||
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
|
||||
scale: "time" as const,
|
||||
// Explicitly specify tick positions so labels appear across the entire range
|
||||
ticks: timeTicks ?? undefined,
|
||||
...baseXAxisProps,
|
||||
}
|
||||
: baseXAxisProps;
|
||||
|
||||
// Bar charts always use categorical axis positioning
|
||||
@@ -883,7 +1006,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
domain: yAxisDomain,
|
||||
};
|
||||
|
||||
const showLegend = series.length > 0;
|
||||
const showLegend = sortedSeries.length > 0;
|
||||
|
||||
if (chartType === "bar") {
|
||||
return (
|
||||
@@ -891,14 +1014,16 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={series}
|
||||
series={sortedSeries}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
state={isLoading ? "loading" : "loaded"}
|
||||
>
|
||||
<Chart.Bar
|
||||
xAxisProps={xAxisPropsForBar}
|
||||
@@ -916,19 +1041,21 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={series}
|
||||
series={sortedSeries}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
state={isLoading ? "loading" : "loaded"}
|
||||
>
|
||||
<Chart.Line
|
||||
xAxisProps={xAxisPropsForLine}
|
||||
yAxisProps={yAxisProps}
|
||||
stacked={stacked && series.length > 1}
|
||||
stacked={stacked && sortedSeries.length > 1}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
lineType="linear"
|
||||
/>
|
||||
@@ -989,12 +1116,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] items-center justify-center">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{message}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
}}
|
||||
/>
|
||||
{showButtons && (
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-1.5">
|
||||
{additionalActions && additionalActions}
|
||||
{showFormatButton && (
|
||||
<Button
|
||||
@@ -338,11 +338,50 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// SQL keywords that legitimately appear before parentheses with a space
|
||||
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
|
||||
"IN",
|
||||
"NOT",
|
||||
"EXISTS",
|
||||
"OVER",
|
||||
"USING",
|
||||
"VALUES",
|
||||
"BETWEEN",
|
||||
"LIKE",
|
||||
"AND",
|
||||
"OR",
|
||||
"ON",
|
||||
"SET",
|
||||
"INTO",
|
||||
"TABLE",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"AS",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"HAVING",
|
||||
"JOIN",
|
||||
"SELECT",
|
||||
]);
|
||||
|
||||
export function autoFormatSQL(sql: string) {
|
||||
return formatSQL(sql, {
|
||||
let formatted = formatSQL(sql, {
|
||||
language: "sql",
|
||||
keywordCase: "upper",
|
||||
indentStyle: "standard",
|
||||
linesBetweenQueries: 2,
|
||||
});
|
||||
|
||||
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
|
||||
// Remove that space for anything that isn't a SQL keyword
|
||||
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
|
||||
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
|
||||
return match;
|
||||
}
|
||||
return `${name}(`;
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { IconFilter2, IconFilter2X, IconTable } from "@tabler/icons-react";
|
||||
import { rankItem } from "@tanstack/match-sorter-utils";
|
||||
import {
|
||||
useReactTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
useReactTable,
|
||||
type CellContext,
|
||||
type ColumnResizeMode,
|
||||
type ColumnFiltersState,
|
||||
type FilterFn,
|
||||
type Column,
|
||||
type SortingState,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ColumnResizeMode,
|
||||
type FilterFn,
|
||||
type SortDirection,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { AlertCircle, ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { forwardRef, memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EnvironmentLabel, EnvironmentSlug } from "~/components/environments/EnvironmentLabel";
|
||||
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
|
||||
@@ -35,16 +37,12 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
|
||||
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { QueueName } from "../runs/v3/QueueName";
|
||||
import {
|
||||
FunnelIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpDownIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
|
||||
const MAX_STRING_DISPLAY_LENGTH = 64;
|
||||
const ROW_HEIGHT = 33; // Estimated row height in pixels
|
||||
@@ -54,7 +52,7 @@ const MIN_COLUMN_WIDTH = 60;
|
||||
const MAX_COLUMN_WIDTH = 400;
|
||||
const CHAR_WIDTH_PX = 7.5; // Approximate width of a monospace character at text-xs (12px)
|
||||
const CELL_PADDING_PX = 40; // px-2 (8px) on each side + buffer for copy button
|
||||
const HEADER_ICONS_WIDTH_PX = 72; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (16px)
|
||||
const HEADER_ICONS_WIDTH_PX = 80; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (24px)
|
||||
const SAMPLE_SIZE = 100; // Number of rows to sample for width calculation
|
||||
|
||||
// Type for row data
|
||||
@@ -160,10 +158,10 @@ const fuzzyFilter: FilterFn<RowData> = (row, columnId, value, addMeta) => {
|
||||
cellValue === null
|
||||
? "NULL"
|
||||
: cellValue === undefined
|
||||
? ""
|
||||
: typeof cellValue === "object"
|
||||
? JSON.stringify(cellValue)
|
||||
: String(cellValue);
|
||||
? ""
|
||||
: typeof cellValue === "object"
|
||||
? JSON.stringify(cellValue)
|
||||
: String(cellValue);
|
||||
|
||||
// Build searchable strings - formatted value (if we have column metadata)
|
||||
const formattedValue = meta?.outputColumn
|
||||
@@ -416,7 +414,7 @@ function CellValueWrapper({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex-1"
|
||||
className="flex flex-1 items-center"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
@@ -462,6 +460,7 @@ function CellValue({
|
||||
</pre>
|
||||
}
|
||||
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -482,7 +481,14 @@ function CellValue({
|
||||
switch (column.customRenderType) {
|
||||
case "runId": {
|
||||
if (typeof value === "string") {
|
||||
return <TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content="Jump to run"
|
||||
disableHoverableContent
|
||||
hidden={!hovered}
|
||||
button={<TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -490,19 +496,17 @@ function CellValue({
|
||||
const status = isTaskRunStatus(value)
|
||||
? value
|
||||
: isRunFriendlyStatus(value)
|
||||
? runStatusFromFriendlyTitle(value)
|
||||
: undefined;
|
||||
? runStatusFromFriendlyTitle(value)
|
||||
: undefined;
|
||||
if (status) {
|
||||
if (hovered) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={descriptionForTaskRunStatus(status)}
|
||||
disableHoverableContent
|
||||
button={<TaskRunStatusCombo status={status} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <TaskRunStatusCombo status={status} />;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={descriptionForTaskRunStatus(status)}
|
||||
disableHoverableContent
|
||||
hidden={!hovered}
|
||||
button={<TaskRunStatusCombo status={status} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -607,6 +611,7 @@ function CellValue({
|
||||
{truncateString(arrayString)}
|
||||
</span>
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -642,6 +647,7 @@ function CellValue({
|
||||
</pre>
|
||||
}
|
||||
button={<span>{truncateString(stringValue)}</span>}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -688,6 +694,7 @@ function JSONCellValue({ value }: { value: unknown }) {
|
||||
button={
|
||||
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -713,15 +720,16 @@ function CopyableCell({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full items-center overflow-hidden px-2 py-1.5",
|
||||
"bg-background-dimmed group-hover/row:bg-charcoal-800",
|
||||
"relative flex h-full w-full items-center overflow-hidden px-2",
|
||||
"bg-background-bright group-hover/row:bg-charcoal-750",
|
||||
"font-mono text-xs text-text-dimmed group-hover/row:text-text-bright",
|
||||
"[&_a:focus-visible]:underline [&_a:focus-visible]:underline-offset-[3px] [&_a:focus-visible]:outline-none",
|
||||
alignment === "right" && "justify-end"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
<span className="flex items-center truncate">{children}</span>
|
||||
{isHovered && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
@@ -781,18 +789,21 @@ function HeaderCellContent({
|
||||
onSortClick?: (event: React.MouseEvent) => void;
|
||||
canSort?: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isCellHovered, setIsCellHovered] = useState(false);
|
||||
const [isFilterHovered, setIsFilterHovered] = useState(false);
|
||||
|
||||
const sortHighlighted = isCellHovered && !isFilterHovered;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 overflow-hidden bg-background-dimmed py-1.5 pl-2 pr-1",
|
||||
"flex w-full items-center gap-1 overflow-hidden bg-background-bright py-2 pl-2 pr-3",
|
||||
"font-mono text-xs font-medium text-text-bright",
|
||||
alignment === "right" && "justify-end",
|
||||
canSort && "cursor-pointer select-none"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onMouseEnter={() => setIsCellHovered(true)}
|
||||
onMouseLeave={() => setIsCellHovered(false)}
|
||||
onClick={onSortClick}
|
||||
>
|
||||
{tooltip ? (
|
||||
@@ -802,11 +813,14 @@ function HeaderCellContent({
|
||||
})}
|
||||
>
|
||||
<span className="truncate text-left">{children}</span>
|
||||
<InfoIconTooltip
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isHovered}
|
||||
/>
|
||||
<span className="flex flex-shrink-0">
|
||||
<InfoIconTooltip
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isCellHovered}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
|
||||
@@ -814,7 +828,10 @@ function HeaderCellContent({
|
||||
{/* Sort indicator */}
|
||||
{canSort && (
|
||||
<span
|
||||
className={cn("flex-shrink-0", sortDirection ? "text-text-bright" : "text-text-dimmed")}
|
||||
className={cn(
|
||||
"flex-shrink-0 transition-colors",
|
||||
sortHighlighted ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{sortDirection === "asc" ? (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
@@ -831,10 +848,12 @@ function HeaderCellContent({
|
||||
e.stopPropagation();
|
||||
onFilterClick();
|
||||
}}
|
||||
className="flex-shrink-0 rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
|
||||
onMouseEnter={() => setIsFilterHovered(true)}
|
||||
onMouseLeave={() => setIsFilterHovered(false)}
|
||||
className="flex-shrink-0 rounded text-text-dimmed transition-colors focus-custom hover:text-text-bright"
|
||||
title="Toggle column filters"
|
||||
>
|
||||
<FunnelIcon className="size-3" />
|
||||
{showFilters ? <IconFilter2X className="size-4" /> : <IconFilter2 className="size-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -866,7 +885,7 @@ function FilterCell({
|
||||
}, [shouldFocus, onFocused]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-background-dimmed px-1.5 pb-1" style={{ width }}>
|
||||
<div className="flex items-center bg-background-bright px-1.5 pb-2" style={{ width }}>
|
||||
<DebouncedInput
|
||||
ref={inputRef}
|
||||
value={columnFilterValue ?? ""}
|
||||
@@ -886,10 +905,15 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
rows,
|
||||
columns,
|
||||
prettyFormatting = true,
|
||||
sorting: defaultSorting = [],
|
||||
showHeaderOnEmpty = false,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
prettyFormatting?: boolean;
|
||||
sorting?: SortingState;
|
||||
/** When true, show column headers + "No results" on empty data. When false, show a blank state icon. */
|
||||
showHeaderOnEmpty?: boolean;
|
||||
}) {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -899,7 +923,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
// Track which column's filter should be focused
|
||||
const [focusFilterColumn, setFocusFilterColumn] = useState<string | null>(null);
|
||||
// State for column sorting
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSorting);
|
||||
|
||||
// Create TanStack Table column definitions from OutputColumnMetadata
|
||||
// Calculate column widths based on content
|
||||
@@ -959,6 +983,10 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
|
||||
// Empty state
|
||||
if (rows.length === 0) {
|
||||
if (!showHeaderOnEmpty) {
|
||||
return <ChartBlankState icon={IconTable} message="No data to display" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full min-h-0 w-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
@@ -966,7 +994,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
>
|
||||
<table style={{ display: "grid" }}>
|
||||
<thead
|
||||
className="bg-background-dimmed"
|
||||
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
|
||||
style={{
|
||||
display: "grid",
|
||||
position: "sticky",
|
||||
@@ -987,63 +1015,24 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
<HeaderCellContent
|
||||
alignment={meta?.alignment ?? "left"}
|
||||
tooltip={meta?.outputColumn.description}
|
||||
onFilterClick={() => {
|
||||
if (!showFilters) {
|
||||
setFocusFilterColumn(header.id);
|
||||
} else {
|
||||
setColumnFilters([]);
|
||||
}
|
||||
setShowFilters(!showFilters);
|
||||
}}
|
||||
showFilters={showFilters}
|
||||
hasActiveFilter={!!header.column.getFilterValue()}
|
||||
sortDirection={header.column.getIsSorted()}
|
||||
onSortClick={header.column.getToggleSortingHandler()}
|
||||
canSort={header.column.getCanSort()}
|
||||
>
|
||||
<HeaderCellContent alignment={meta?.alignment ?? "left"}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</HeaderCellContent>
|
||||
{/* Column resizer */}
|
||||
<div
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
|
||||
"opacity-0 group-hover/header:opacity-100",
|
||||
"bg-charcoal-600 hover:bg-indigo-500",
|
||||
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
|
||||
)}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{/* Filter row - shown when filters are toggled */}
|
||||
{showFilters && (
|
||||
<tr style={{ display: "flex", width: "100%" }}>
|
||||
{table.getHeaderGroups()[0]?.headers.map((header) => (
|
||||
<FilterCell
|
||||
key={`filter-${header.id}`}
|
||||
column={header.column}
|
||||
width={header.getSize()}
|
||||
shouldFocus={focusFilterColumn === header.id}
|
||||
onFocused={() => setFocusFilterColumn(null)}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody style={{ display: "grid" }}>
|
||||
<tr style={{ display: "flex" }}>
|
||||
<td>
|
||||
<Paragraph variant="extra-small" className="p-4 text-text-dimmed">
|
||||
No results
|
||||
</Paragraph>
|
||||
<tr style={{ display: "flex", width: "100%" }}>
|
||||
<td className="w-full px-3 py-6" colSpan={columns.length}>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<AlertCircle className="size-5 text-text-dimmed/50" />
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
This query returned no results
|
||||
</Paragraph>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -1060,7 +1049,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
>
|
||||
<table style={{ display: "grid" }}>
|
||||
<thead
|
||||
className="bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
|
||||
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
|
||||
style={{
|
||||
display: "grid",
|
||||
position: "sticky",
|
||||
@@ -1107,7 +1096,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none",
|
||||
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
|
||||
"opacity-0 group-hover/header:opacity-100",
|
||||
"bg-charcoal-600 hover:bg-indigo-500",
|
||||
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
|
||||
@@ -1139,7 +1128,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
className="bg-background-dimmed divide-y divide-charcoal-700"
|
||||
className="divide-y divide-charcoal-700 bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:z-[1] after:h-px after:bg-grid-bright"
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = tableRows[virtualRow.index];
|
||||
@@ -1147,12 +1136,13 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
<tr
|
||||
key={row.id}
|
||||
data-index={virtualRow.index}
|
||||
className="group/row hover:bg-charcoal-800"
|
||||
className="group/row hover:bg-charcoal-750"
|
||||
style={{
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
|
||||
*
|
||||
* HSL is a human-friendly color model:
|
||||
* h: 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>
|
||||
)}
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
ServerStackIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
UsersIcon
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Link, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import { LayoutGroup, motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import simplur from "simplur";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
@@ -40,9 +40,8 @@ import { useFeatureFlags } from "~/hooks/useFeatureFlags";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { type MatchedProject } from "~/hooks/useProject";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { type UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { type FeedbackType } from "~/routes/resources.feedback";
|
||||
@@ -65,6 +64,7 @@ import {
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
v3BillingPath,
|
||||
v3BuiltInDashboardPath,
|
||||
v3BulkActionsPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentPath,
|
||||
@@ -88,23 +88,39 @@ import { ImpersonationBanner } from "../ImpersonationBanner";
|
||||
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
|
||||
import { Dialog, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverTrigger
|
||||
} from "../primitives/Popover";
|
||||
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import {
|
||||
SimpleTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../primitives/Tooltip";
|
||||
import { ShortcutsAutoOpen } from "../Shortcuts";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { CreateDashboardButton } from "./DashboardDialogs";
|
||||
import { DashboardList } from "./DashboardList";
|
||||
import { EnvironmentSelector } from "./EnvironmentSelector";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuSection } from "./SideMenuSection";
|
||||
import { type SideMenuSectionId } from "./sideMenuTypes";
|
||||
|
||||
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
|
||||
/** Get the collapsed state for a specific side menu section from user preferences */
|
||||
function getSectionCollapsed(
|
||||
sideMenu: { collapsedSections?: Record<string, boolean> } | undefined,
|
||||
sectionId: SideMenuSectionId
|
||||
): boolean {
|
||||
return sideMenu?.collapsedSections?.[sectionId] ?? false;
|
||||
}
|
||||
|
||||
type SideMenuUser = Pick<
|
||||
UserWithDashboardPreferences,
|
||||
"email" | "admin" | "dashboardPreferences"
|
||||
> & {
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
export type SideMenuProject = Pick<
|
||||
@@ -138,7 +154,8 @@ export function SideMenu({
|
||||
const preferencesFetcher = useFetcher();
|
||||
const pendingPreferencesRef = useRef<{
|
||||
isCollapsed?: boolean;
|
||||
manageSectionCollapsed?: boolean;
|
||||
sectionId?: SideMenuSectionId;
|
||||
sectionCollapsed?: boolean;
|
||||
}>({});
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const currentPlan = useCurrentPlan();
|
||||
@@ -149,7 +166,11 @@ export function SideMenu({
|
||||
const featureFlags = useFeatureFlags();
|
||||
|
||||
const persistSideMenuPreferences = useCallback(
|
||||
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
|
||||
(data: {
|
||||
isCollapsed?: boolean;
|
||||
sectionId?: SideMenuSectionId;
|
||||
sectionCollapsed?: boolean;
|
||||
}) => {
|
||||
if (user.isImpersonating) return;
|
||||
|
||||
// Merge with any pending changes
|
||||
@@ -170,8 +191,9 @@ export function SideMenu({
|
||||
if (pending.isCollapsed !== undefined) {
|
||||
formData.append("isCollapsed", String(pending.isCollapsed));
|
||||
}
|
||||
if (pending.manageSectionCollapsed !== undefined) {
|
||||
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
|
||||
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
|
||||
formData.append("sectionId", pending.sectionId);
|
||||
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
|
||||
}
|
||||
preferencesFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
@@ -191,13 +213,18 @@ export function SideMenu({
|
||||
}
|
||||
if (user.isImpersonating) return;
|
||||
const pending = pendingPreferencesRef.current;
|
||||
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
|
||||
const hasPendingChanges =
|
||||
pending.isCollapsed !== undefined ||
|
||||
(pending.sectionId !== undefined && pending.sectionCollapsed !== undefined);
|
||||
|
||||
if (hasPendingChanges) {
|
||||
const formData = new FormData();
|
||||
if (pending.isCollapsed !== undefined) {
|
||||
formData.append("isCollapsed", String(pending.isCollapsed));
|
||||
}
|
||||
if (pending.manageSectionCollapsed !== undefined) {
|
||||
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
|
||||
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
|
||||
formData.append("sectionId", pending.sectionId);
|
||||
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
|
||||
}
|
||||
preferencesFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
@@ -214,9 +241,10 @@ export function SideMenu({
|
||||
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
|
||||
};
|
||||
|
||||
const handleManageSectionToggle = useCallback(
|
||||
(collapsed: boolean) => {
|
||||
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
|
||||
/** Generic handler for any collapsible section - just pass the section ID */
|
||||
const handleSectionToggle = useCallback(
|
||||
(sectionId: SideMenuSectionId) => (collapsed: boolean) => {
|
||||
persistSideMenuPreferences({ sectionId, sectionCollapsed: collapsed });
|
||||
},
|
||||
[persistSideMenuPreferences]
|
||||
);
|
||||
@@ -255,294 +283,341 @@ export function SideMenu({
|
||||
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
|
||||
<ProjectSelector
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
project={project}
|
||||
user={user}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
|
||||
<ProjectSelector
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
project={project}
|
||||
user={user}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && !user.isImpersonating ? (
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LinkButton
|
||||
variant="minimal/medium"
|
||||
to={adminPath()}
|
||||
TrailingIcon={UsersIcon}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={"text-xs"}>
|
||||
Admin dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CollapsibleElement>
|
||||
) : isAdmin && user.isImpersonating ? (
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<ImpersonationBanner />
|
||||
</CollapsibleElement>
|
||||
) : null}
|
||||
</div>
|
||||
{isAdmin && !user.isImpersonating ? (
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={"text-xs"}>
|
||||
Admin dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CollapsibleElement>
|
||||
) : isAdmin && user.isImpersonating ? (
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<ImpersonationBanner />
|
||||
</CollapsibleElement>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 overflow-y-auto pt-2",
|
||||
isCollapsed
|
||||
? "scrollbar-none"
|
||||
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
)}
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
|
||||
<div className="w-full space-y-1">
|
||||
<SideMenuHeader title={"Environment"} isCollapsed={isCollapsed} collapsedTitle="Env" />
|
||||
<div className="flex items-center">
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-full"
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 overflow-y-auto pt-2",
|
||||
isCollapsed
|
||||
? "scrollbar-none"
|
||||
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
)}
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
|
||||
<div className="w-full space-y-1">
|
||||
<SideMenuHeader
|
||||
title={"Environment"}
|
||||
isCollapsed={isCollapsed}
|
||||
collapsedTitle="Env"
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<Dialog>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex">
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
className="aspect-square h-7 p-1"
|
||||
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={"text-xs"}>
|
||||
{isConnected === undefined
|
||||
? "Checking connection..."
|
||||
: isConnected
|
||||
? "Your dev server is connected"
|
||||
: "Your dev server is not connected"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DevPresencePanel isConnected={isConnected} />
|
||||
</Dialog>
|
||||
</CollapsibleElement>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-full"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<Dialog>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex">
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
className="aspect-square h-7 p-1"
|
||||
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={"text-xs"}>
|
||||
{isConnected === undefined
|
||||
? "Checking connection..."
|
||||
: isConnected
|
||||
? "Your dev server is connected"
|
||||
: "Your dev server is not connected"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DevPresencePanel isConnected={isConnected} />
|
||||
</Dialog>
|
||||
</CollapsibleElement>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<SideMenuItem
|
||||
name="Tasks"
|
||||
icon={TaskIconSmall}
|
||||
activeIconColor="text-tasks"
|
||||
inactiveIconColor="text-tasks"
|
||||
to={v3EnvironmentPath(organization, project, environment)}
|
||||
data-action="tasks"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon={RunsIconExtraSmall}
|
||||
activeIconColor="text-runs"
|
||||
inactiveIconColor="text-runs"
|
||||
to={v3RunsPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Batches"
|
||||
icon={Squares2X2Icon}
|
||||
activeIconColor="text-batches"
|
||||
inactiveIconColor="text-batches"
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
data-action="batches"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Schedules"
|
||||
icon={ClockIcon}
|
||||
activeIconColor="text-schedules"
|
||||
inactiveIconColor="text-schedules"
|
||||
to={v3SchedulesPath(organization, project, environment)}
|
||||
data-action="schedules"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Queues"
|
||||
icon={RectangleStackIcon}
|
||||
activeIconColor="text-queues"
|
||||
inactiveIconColor="text-queues"
|
||||
to={v3QueuesPath(organization, project, environment)}
|
||||
data-action="queues"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Waitpoint tokens"
|
||||
icon={WaitpointTokenIcon}
|
||||
activeIconColor="text-sky-500"
|
||||
inactiveIconColor="text-sky-500"
|
||||
to={v3WaitpointTokensPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Deployments"
|
||||
icon={ServerStackIcon}
|
||||
activeIconColor="text-deployments"
|
||||
inactiveIconColor="text-deployments"
|
||||
to={v3DeploymentsPath(organization, project, environment)}
|
||||
data-action="deployments"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
|
||||
<div className="w-full">
|
||||
<SideMenuItem
|
||||
name="Logs"
|
||||
icon={LogsIcon}
|
||||
activeIconColor="text-logs"
|
||||
inactiveIconColor="text-logs"
|
||||
to={v3LogsPath(organization, project, environment)}
|
||||
data-action="logs"
|
||||
badge={<AlphaBadge />}
|
||||
name="Tasks"
|
||||
icon={TaskIconSmall}
|
||||
activeIconColor="text-tasks"
|
||||
inactiveIconColor="text-tasks"
|
||||
to={v3EnvironmentPath(organization, project, environment)}
|
||||
data-action="tasks"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
activeIconColor="text-tests"
|
||||
inactiveIconColor="text-tests"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
data-action="test"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon={RunsIconExtraSmall}
|
||||
activeIconColor="text-runs"
|
||||
inactiveIconColor="text-runs"
|
||||
to={v3RunsPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Batches"
|
||||
icon={Squares2X2Icon}
|
||||
activeIconColor="text-batches"
|
||||
inactiveIconColor="text-batches"
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
data-action="batches"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Schedules"
|
||||
icon={ClockIcon}
|
||||
activeIconColor="text-schedules"
|
||||
inactiveIconColor="text-schedules"
|
||||
to={v3SchedulesPath(organization, project, environment)}
|
||||
data-action="schedules"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Queues"
|
||||
icon={RectangleStackIcon}
|
||||
activeIconColor="text-queues"
|
||||
inactiveIconColor="text-queues"
|
||||
to={v3QueuesPath(organization, project, environment)}
|
||||
data-action="queues"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Waitpoint tokens"
|
||||
icon={WaitpointTokenIcon}
|
||||
activeIconColor="text-sky-500"
|
||||
inactiveIconColor="text-sky-500"
|
||||
to={v3WaitpointTokensPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Deployments"
|
||||
icon={ServerStackIcon}
|
||||
activeIconColor="text-deployments"
|
||||
inactiveIconColor="text-deployments"
|
||||
to={v3DeploymentsPath(organization, project, environment)}
|
||||
data-action="deployments"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
activeIconColor="text-tests"
|
||||
inactiveIconColor="text-tests"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
data-action="test"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
|
||||
<SideMenuItem
|
||||
name="Query"
|
||||
icon={TableCellsIcon}
|
||||
activeIconColor="text-purple-500"
|
||||
inactiveIconColor="text-purple-500"
|
||||
to={queryPath(organization, project, environment)}
|
||||
data-action="query"
|
||||
badge={<AlphaBadge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuSection
|
||||
title="Insights"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={getSectionCollapsed(
|
||||
user.dashboardPreferences.sideMenu,
|
||||
"metrics"
|
||||
)}
|
||||
onCollapseToggle={handleSectionToggle("metrics")}
|
||||
>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
|
||||
<SideMenuItem
|
||||
name="Logs"
|
||||
icon={LogsIcon}
|
||||
activeIconColor="text-logs"
|
||||
inactiveIconColor="text-logs"
|
||||
to={v3LogsPath(organization, project, environment)}
|
||||
data-action="logs"
|
||||
badge={<AlphaBadge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Query"
|
||||
icon={TableCellsIcon}
|
||||
activeIconColor="text-query"
|
||||
inactiveIconColor="text-query"
|
||||
to={queryPath(organization, project, environment)}
|
||||
data-action="query"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Metrics"
|
||||
icon={ChartBarIcon}
|
||||
activeIconColor="text-metrics"
|
||||
inactiveIconColor="text-metrics"
|
||||
to={v3BuiltInDashboardPath(organization, project, environment, "overview")}
|
||||
data-action="metrics-overview"
|
||||
isCollapsed={isCollapsed}
|
||||
action={
|
||||
<CreateDashboardButton
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DashboardList
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
isCollapsed={isCollapsed}
|
||||
user={user}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SideMenuSection
|
||||
title="Manage"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false}
|
||||
onCollapseToggle={handleManageSectionToggle}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Bulk actions"
|
||||
icon={ListCheckedIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3BulkActionsPath(organization, project, environment)}
|
||||
data-action="bulk actions"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="API keys"
|
||||
icon={KeyIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ApiKeysPath(organization, project, environment)}
|
||||
data-action="api keys"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environment variables"
|
||||
icon={IdentificationIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
||||
data-action="environment variables"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectAlertsPath(organization, project, environment)}
|
||||
data-action="alerts"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Preview branches"
|
||||
icon={BranchEnvironmentIconSmall}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={branchesPath(organization, project, environment)}
|
||||
data-action="preview-branches"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{isManagedCloud && (
|
||||
<SideMenuSection
|
||||
title="Manage"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "manage")}
|
||||
onCollapseToggle={handleSectionToggle("manage")}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Concurrency"
|
||||
icon={ConcurrencyIcon}
|
||||
name="Bulk actions"
|
||||
icon={ListCheckedIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={concurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
to={v3BulkActionsPath(organization, project, environment)}
|
||||
data-action="bulk actions"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Regions"
|
||||
icon={GlobeAmericasIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={regionsPath(organization, project, environment)}
|
||||
data-action="regions"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Limits"
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={limitsPath(organization, project, environment)}
|
||||
data-action="limits"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
data-action="project-settings"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IncidentStatusPanel isCollapsed={isCollapsed} />
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={cn("flex flex-col gap-1 border-t border-grid-bright p-1", isCollapsed && "items-center")}
|
||||
>
|
||||
<HelpAndAI isCollapsed={isCollapsed} />
|
||||
{isFreeUser && (
|
||||
<CollapsibleHeight isCollapsed={isCollapsed}>
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
<SideMenuItem
|
||||
name="API keys"
|
||||
icon={KeyIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ApiKeysPath(organization, project, environment)}
|
||||
data-action="api keys"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</CollapsibleHeight>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
<SideMenuItem
|
||||
name="Environment variables"
|
||||
icon={IdentificationIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
||||
data-action="environment variables"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectAlertsPath(organization, project, environment)}
|
||||
data-action="alerts"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Preview branches"
|
||||
icon={BranchEnvironmentIconSmall}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={branchesPath(organization, project, environment)}
|
||||
data-action="preview-branches"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Concurrency"
|
||||
icon={ConcurrencyIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={concurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Regions"
|
||||
icon={GlobeAmericasIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={regionsPath(organization, project, environment)}
|
||||
data-action="regions"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Limits"
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={limitsPath(organization, project, environment)}
|
||||
data-action="limits"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
data-action="project-settings"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IncidentStatusPanel isCollapsed={isCollapsed} />
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 border-t border-grid-bright p-1",
|
||||
isCollapsed && "items-center"
|
||||
)}
|
||||
>
|
||||
<HelpAndAI isCollapsed={isCollapsed} />
|
||||
{isFreeUser && (
|
||||
<CollapsibleHeight isCollapsed={isCollapsed}>
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
</CollapsibleHeight>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -890,7 +965,12 @@ function CollapsibleHeight({
|
||||
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full",
|
||||
isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between"
|
||||
)}
|
||||
>
|
||||
<ShortcutsAutoOpen />
|
||||
<HelpAndFeedback isCollapsed={isCollapsed} />
|
||||
<AskAI isCollapsed={isCollapsed} />
|
||||
@@ -909,7 +989,7 @@ function AnimatedChevron({
|
||||
// When hovering and expanded: left chevron (pointing left to collapse)
|
||||
// When hovering and collapsed: right chevron (pointing right to expand)
|
||||
// When not hovering: straight vertical line
|
||||
|
||||
|
||||
const getRotation = () => {
|
||||
if (!isHovering) return { top: 0, bottom: 0 };
|
||||
if (isCollapsed) {
|
||||
@@ -922,7 +1002,7 @@ function AnimatedChevron({
|
||||
};
|
||||
|
||||
const { top, bottom } = getRotation();
|
||||
|
||||
|
||||
// Calculate horizontal offset to keep chevron centered when rotated
|
||||
// Left chevron: translate left (-1.5px)
|
||||
// Right chevron: translate right (+1.5px)
|
||||
@@ -938,7 +1018,7 @@ function AnimatedChevron({
|
||||
viewBox="0 0 4 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 group-hover:text-text-bright transition-colors"
|
||||
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 transition-colors group-hover:text-text-bright"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: getTranslateX(),
|
||||
@@ -981,22 +1061,18 @@ function AnimatedChevron({
|
||||
);
|
||||
}
|
||||
|
||||
function CollapseToggle({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="absolute -right-3 top-1/2 z-10 -translate-y-1/2">
|
||||
{/* Vertical line to mask the side menu border */}
|
||||
<div className={cn(
|
||||
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
|
||||
isHovering ? "bg-charcoal-750" : "bg-background-bright"
|
||||
)} />
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
|
||||
isHovering ? "bg-charcoal-750" : "bg-background-bright"
|
||||
)}
|
||||
/>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -17,6 +17,7 @@ export function SideMenuItem({
|
||||
badge,
|
||||
target,
|
||||
isCollapsed = false,
|
||||
action,
|
||||
}: {
|
||||
icon?: RenderIcon;
|
||||
activeIconColor?: string;
|
||||
@@ -28,59 +29,84 @@ export function SideMenuItem({
|
||||
badge?: ReactNode;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
isCollapsed?: boolean;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Link
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750",
|
||||
isActive ? "bg-tertiary" : ""
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
"size-5 shrink-0",
|
||||
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
const link = (
|
||||
<Link
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750 group-hover/menuitem:bg-charcoal-750",
|
||||
isActive ? "bg-tertiary" : ""
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
"size-5 shrink-0",
|
||||
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<span className="truncate select-none text-2sm">{name}</span>
|
||||
{badge && !isCollapsed && (
|
||||
<motion.div
|
||||
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
|
||||
className="ml-1 flex shrink-0 items-center gap-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<span className="truncate text-2sm">{name}</span>
|
||||
{badge && !isCollapsed && (
|
||||
<motion.div
|
||||
className="ml-1 flex shrink-0 items-center gap-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{badge}
|
||||
</motion.div>
|
||||
)}
|
||||
{trailingIcon && !isCollapsed && (
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
|
||||
/>
|
||||
)}
|
||||
{badge}
|
||||
</motion.div>
|
||||
</Link>
|
||||
}
|
||||
)}
|
||||
{trailingIcon && !isCollapsed && (
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (action) {
|
||||
return (
|
||||
<div className="group/menuitem relative h-8 w-full">
|
||||
<SimpleTooltip
|
||||
button={link}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="!h-8 block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="absolute top-1 right-1 bottom-1 flex aspect-square items-center justify-center rounded group-hover/menuitem:bg-charcoal-750">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={link}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
|
||||
@@ -10,6 +10,8 @@ type Props = {
|
||||
/** When true, hides the section header and shows only children */
|
||||
isSideMenuCollapsed?: boolean;
|
||||
itemSpacingClassName?: string;
|
||||
/** Optional action element (e.g., + button) to render on the right side of the header */
|
||||
headerAction?: React.ReactNode;
|
||||
};
|
||||
|
||||
/** A collapsible section for the side menu
|
||||
@@ -22,6 +24,7 @@ export function SideMenuSection({
|
||||
children,
|
||||
isSideMenuCollapsed = false,
|
||||
itemSpacingClassName = "space-y-px",
|
||||
headerAction,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
||||
|
||||
@@ -37,23 +40,26 @@ export function SideMenuSection({
|
||||
<div className="relative w-full">
|
||||
{/* Header - fades out when sidebar is collapsed */}
|
||||
<motion.div
|
||||
className="flex cursor-pointer items-center gap-1 overflow-hidden rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
|
||||
onClick={isSideMenuCollapsed ? undefined : handleToggle}
|
||||
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
|
||||
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-charcoal-750"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
onClick={isSideMenuCollapsed ? undefined : handleToggle}
|
||||
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
|
||||
>
|
||||
<h2 className="text-xs whitespace-nowrap">{title}</h2>
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
</motion.div>
|
||||
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
|
||||
<h2 className="whitespace-nowrap text-xs">{title}</h2>
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
</motion.div>
|
||||
</div>
|
||||
{headerAction && <div className="flex items-center">{headerAction}</div>}
|
||||
</motion.div>
|
||||
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
|
||||
<motion.div
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
// Tree connector icons for sub-items. The SVG viewBox is 20x20 matching the size-5 icon area.
|
||||
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
|
||||
export function TreeConnectorBranch({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("overflow-visible", className, "text-charcoal-600")}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeConnectorEnd({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("overflow-visible", className, "text-charcoal-600")}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Valid section IDs that can have their collapsed state toggled
|
||||
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
|
||||
|
||||
// Inferred type from the schema
|
||||
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Layout, useContainerWidth } from "react-grid-layout";
|
||||
|
||||
/**
|
||||
* Generic hook for managing a reorderable list in the side menu.
|
||||
*
|
||||
* Handles order state, sorting, grid layout, drag callbacks, and persistence
|
||||
* via the `/resources/preferences/sidemenu` resource route.
|
||||
*
|
||||
* @param organizationId - Organization ID for scoping the persisted order
|
||||
* @param listId - Identifier for this list (e.g. "customDashboards")
|
||||
* @param items - The items to reorder
|
||||
* @param itemKey - Extract a stable string key from each item
|
||||
* @param initialOrder - Initial order from stored preferences (if any)
|
||||
* @param isImpersonating - Skip persistence when impersonating
|
||||
*/
|
||||
export function useReorderableList<T>({
|
||||
organizationId,
|
||||
listId,
|
||||
items,
|
||||
itemKey,
|
||||
initialOrder,
|
||||
isImpersonating,
|
||||
}: {
|
||||
organizationId: string;
|
||||
listId: string;
|
||||
items: T[];
|
||||
itemKey: (item: T) => string;
|
||||
initialOrder: string[] | undefined;
|
||||
isImpersonating: boolean;
|
||||
}) {
|
||||
const orderFetcher = useFetcher();
|
||||
|
||||
const [order, setOrder] = useState<string[]>(
|
||||
() => initialOrder ?? items.map(itemKey)
|
||||
);
|
||||
|
||||
// Sync order when organizationId changes (component may not remount)
|
||||
useEffect(() => {
|
||||
setOrder(initialOrder ?? items.map(itemKey));
|
||||
}, [organizationId]);
|
||||
|
||||
// Sort items by stored order, new items go to end
|
||||
const orderedItems = useMemo(() => {
|
||||
const orderMap = new Map(order.map((id, i) => [id, i]));
|
||||
return [...items].sort((a, b) => {
|
||||
const aIdx = orderMap.get(itemKey(a)) ?? Infinity;
|
||||
const bIdx = orderMap.get(itemKey(b)) ?? Infinity;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
}, [items, order, itemKey]);
|
||||
|
||||
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
orderedItems.map((item, i) => ({
|
||||
i: itemKey(item),
|
||||
x: 0,
|
||||
y: i,
|
||||
w: 1,
|
||||
h: 1,
|
||||
})),
|
||||
[orderedItems, itemKey]
|
||||
);
|
||||
|
||||
// Width measurement for ReactGridLayout
|
||||
const {
|
||||
width: gridWidth,
|
||||
containerRef,
|
||||
mounted: gridMounted,
|
||||
} = useContainerWidth({ initialWidth: 216 });
|
||||
|
||||
const canReorder = orderedItems.length >= 2;
|
||||
|
||||
// Track layout during drag for real-time visual updates
|
||||
const [dragLayout, setDragLayout] = useState<Layout | null>(null);
|
||||
|
||||
const handleDrag = useCallback((layout: Layout) => {
|
||||
setDragLayout(layout);
|
||||
}, []);
|
||||
|
||||
// Handle drag stop - extract new order from layout y-positions
|
||||
const handleDragStop = useCallback(
|
||||
(layout: Layout) => {
|
||||
setDragLayout(null);
|
||||
const sorted = [...layout].sort((a, b) => a.y - b.y);
|
||||
const newOrder = sorted.map((item) => item.i);
|
||||
if (JSON.stringify(newOrder) === JSON.stringify(order)) return;
|
||||
setOrder(newOrder);
|
||||
// Persist immediately
|
||||
if (!isImpersonating) {
|
||||
const formData = new FormData();
|
||||
formData.append("organizationId", organizationId);
|
||||
formData.append("listId", listId);
|
||||
formData.append("itemOrder", JSON.stringify(newOrder));
|
||||
orderFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
action: "/resources/preferences/sidemenu",
|
||||
});
|
||||
}
|
||||
},
|
||||
[order, organizationId, listId, isImpersonating, orderFetcher]
|
||||
);
|
||||
|
||||
// Compute which item is visually last (during drag or at rest)
|
||||
const getIsLast = useCallback(
|
||||
(key: string, index: number) => {
|
||||
if (dragLayout) {
|
||||
const maxY = Math.max(...dragLayout.map((l) => l.y));
|
||||
return dragLayout.find((l) => l.i === key)?.y === maxY;
|
||||
}
|
||||
return index === orderedItems.length - 1;
|
||||
},
|
||||
[dragLayout, orderedItems.length]
|
||||
);
|
||||
|
||||
return {
|
||||
orderedItems,
|
||||
layout,
|
||||
containerRef: containerRef as Ref<HTMLDivElement>,
|
||||
gridWidth,
|
||||
gridMounted,
|
||||
canReorder,
|
||||
handleDrag,
|
||||
handleDragStop,
|
||||
getIsLast,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type AppliedFilterProps = {
|
||||
onRemove?: () => void;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
valueClassName?: string;
|
||||
};
|
||||
|
||||
export function AppliedFilter({
|
||||
@@ -37,6 +38,7 @@ export function AppliedFilter({
|
||||
onRemove,
|
||||
variant = "secondary/small",
|
||||
className,
|
||||
valueClassName,
|
||||
}: AppliedFilterProps) {
|
||||
const variantClassName = variants[variant];
|
||||
return (
|
||||
@@ -48,14 +50,18 @@ export function AppliedFilter({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}>
|
||||
<div
|
||||
className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}
|
||||
>
|
||||
<div className="-mt-[0.5px] flex items-center gap-1">
|
||||
{icon}
|
||||
{label && <div className="text-text-bright">
|
||||
<span>{label}</span>:
|
||||
</div>}
|
||||
{label && (
|
||||
<div className="text-text-bright">
|
||||
<span>{label}</span>:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-text-dimmed">
|
||||
<div className={cn("text-text-dimmed", valueClassName)}>
|
||||
<div>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ const ClientTabs = React.forwardRef<
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
activationMode="manual"
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
@@ -96,6 +97,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
@@ -134,6 +136,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
@@ -143,7 +146,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -170,8 +173,9 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -188,9 +192,11 @@ const ClientTabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
className
|
||||
"mt-1 outline-none",
|
||||
className,
|
||||
"data-[state=inactive]:hidden"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -196,7 +196,11 @@ export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: Date
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales, hour12);
|
||||
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to check if two dates are on the same day
|
||||
@@ -270,14 +274,18 @@ const DateTimeAccurateInner = ({
|
||||
return hideDate
|
||||
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
|
||||
: realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
|
||||
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
|
||||
|
||||
if (!showTooltip)
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
);
|
||||
|
||||
const tooltipContent = (
|
||||
<TooltipContent
|
||||
@@ -290,7 +298,11 @@ const DateTimeAccurateInner = ({
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={<span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>}
|
||||
button={
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
}
|
||||
content={tooltipContent}
|
||||
side="right"
|
||||
asChild={true}
|
||||
@@ -326,9 +338,13 @@ function formatDateTimeAccurate(
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
const datePart = new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone,
|
||||
}).format(date);
|
||||
|
||||
const timePart = new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
@@ -338,7 +354,7 @@ function formatDateTimeAccurate(
|
||||
hour12,
|
||||
}).format(date);
|
||||
|
||||
return formattedDateTime;
|
||||
return `${datePart} ${timePart}`;
|
||||
}
|
||||
|
||||
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
|
||||
@@ -347,7 +363,11 @@ export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
|
||||
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
function formatDateTimeShort(
|
||||
|
||||
@@ -7,7 +7,7 @@ export function FormButtons({
|
||||
className,
|
||||
}: {
|
||||
cancelButton?: React.ReactNode;
|
||||
confirmButton: React.ReactNode;
|
||||
confirmButton?: React.ReactNode;
|
||||
defaultAction?: { name: string; value: string; disabled?: boolean };
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -29,7 +29,7 @@ export function FormButtons({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { AnimatePresence, useAnimate, usePresence } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type LoadingBarDividerProps = {
|
||||
isLoading: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LoadingBarDivider({ isLoading }: LoadingBarDividerProps) {
|
||||
export function LoadingBarDivider({ isLoading, className }: LoadingBarDividerProps) {
|
||||
return (
|
||||
<div className="relative h-px w-full overflow-hidden bg-grid-bright">
|
||||
<div className={cn("relative h-px w-full overflow-hidden bg-grid-bright", className)}>
|
||||
<AnimationDivider isLoading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -243,20 +243,41 @@ function PopoverArrowTrigger({
|
||||
);
|
||||
}
|
||||
|
||||
const popoverVerticalEllipseVariants = {
|
||||
minimal: {
|
||||
trigger:
|
||||
"size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
|
||||
icon: "size-5",
|
||||
},
|
||||
secondary: {
|
||||
trigger:
|
||||
"size-6 rounded border border-charcoal-600 bg-secondary text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550",
|
||||
icon: "size-4",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants;
|
||||
|
||||
function PopoverVerticalEllipseTrigger({
|
||||
isOpen,
|
||||
variant = "minimal",
|
||||
className,
|
||||
...props
|
||||
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
variant?: PopoverVerticalEllipseVariant;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const styles = popoverVerticalEllipseVariants[variant];
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex items-center justify-end gap-1 rounded-[3px] p-0.5 text-text-dimmed transition focus-custom hover:bg-tertiary hover:text-text-bright",
|
||||
"group flex items-center justify-center transition focus-custom",
|
||||
styles.trigger,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<EllipsisVerticalIcon className={cn("size-5 transition group-hover:text-text-bright")} />
|
||||
<EllipsisVerticalIcon className={cn(styles.icon, "transition")} />
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const ResizableHandle = ({
|
||||
// Vertical orientation
|
||||
"data-[handle-orientation=vertical]:h-0.75 data-[handle-orientation=vertical]:w-full",
|
||||
"data-[handle-orientation=vertical]:after:inset-x-0 data-[handle-orientation=vertical]:after:inset-y-auto",
|
||||
"data-[handle-orientation=vertical]:after:top-1/2 data-[handle-orientation=vertical]:after:left-0",
|
||||
"data-[handle-orientation=vertical]:after:left-0 data-[handle-orientation=vertical]:after:top-1/2",
|
||||
"data-[handle-orientation=vertical]:after:h-1 data-[handle-orientation=vertical]:after:w-full",
|
||||
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
|
||||
className
|
||||
@@ -42,9 +42,9 @@ const ResizableHandle = ({
|
||||
{...props}
|
||||
>
|
||||
{/* Horizontal orientation line indicator */}
|
||||
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:hidden" />
|
||||
<div className="absolute left-[0.0625rem] top-0 z-20 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:hidden" />
|
||||
{/* Vertical orientation line indicator */}
|
||||
<div className="absolute left-0 top-[0.0625rem] hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:block" />
|
||||
<div className="absolute left-0 top-[0.0625rem] z-20 hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:block" />
|
||||
{withHandle && (
|
||||
<>
|
||||
{/* Horizontal orientation dots (vertical arrangement) */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const sizes = {
|
||||
@@ -63,7 +64,7 @@ const variants = {
|
||||
type VariantType = keyof typeof variants;
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
value: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ import { cn } from "~/utils/cn";
|
||||
import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const small =
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border transition uppercase";
|
||||
|
||||
const medium =
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
small: cn(small, "border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60"),
|
||||
"small/bright": cn(small, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -54,10 +57,10 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
);
|
||||
}
|
||||
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
function keyString(key: string, isMac: boolean, variant: ShortcutKeyVariant) {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
@@ -86,9 +89,9 @@ function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "m
|
||||
function modifierString(
|
||||
modifier: Modifier,
|
||||
isMac: boolean,
|
||||
variant: "small" | "medium" | "medium/bright"
|
||||
variant: ShortcutKeyVariant
|
||||
): string | JSX.Element {
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3.5 h-5";
|
||||
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-3.5 h-5";
|
||||
|
||||
switch (modifier) {
|
||||
case "alt":
|
||||
|
||||
@@ -87,7 +87,7 @@ function SimpleTooltip({
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
className={cn(!asChild && "h-fit", buttonClassName)}
|
||||
style={buttonStyle}
|
||||
asChild={asChild}
|
||||
>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
interface BigNumberProps {
|
||||
animate?: boolean;
|
||||
loading?: boolean;
|
||||
value?: number;
|
||||
valueClassName?: string;
|
||||
defaultValue?: number;
|
||||
suffix?: string;
|
||||
suffixClassName?: string;
|
||||
}
|
||||
|
||||
export function BigNumber({
|
||||
value,
|
||||
defaultValue,
|
||||
valueClassName,
|
||||
suffix,
|
||||
suffixClassName,
|
||||
animate = false,
|
||||
loading = false,
|
||||
}: BigNumberProps) {
|
||||
const v = value ?? defaultValue;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full text-[3.75rem] font-normal tabular-nums leading-none text-text-bright",
|
||||
valueClassName
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : v !== undefined ? (
|
||||
<div className="flex items-baseline gap-1">
|
||||
{animate ? <AnimatedNumber value={v} /> : v}
|
||||
{suffix && <div className={cn("text-xs", suffixClassName)}>{suffix}</div>}
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { OutputColumnMetadata } from "@internal/tsql";
|
||||
import { Hash } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
BigNumberAggregationType,
|
||||
BigNumberConfiguration,
|
||||
} from "~/components/metrics/QueryWidget";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
import { ChartBlankState } from "./ChartBlankState";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
interface BigNumberCardProps {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
config: BigNumberConfiguration;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts numeric values from a specific column across all rows,
|
||||
* optionally sorting them first.
|
||||
*/
|
||||
function extractColumnValues(
|
||||
rows: Record<string, unknown>[],
|
||||
column: string,
|
||||
sortDirection?: "asc" | "desc"
|
||||
): number[] {
|
||||
const values: number[] = [];
|
||||
const sortedRows = sortDirection
|
||||
? [...rows].sort((a, b) => {
|
||||
const aVal = toNumber(a[column]);
|
||||
const bVal = toNumber(b[column]);
|
||||
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
|
||||
})
|
||||
: rows;
|
||||
|
||||
for (const row of sortedRows) {
|
||||
const val = row[column];
|
||||
if (typeof val === "number") {
|
||||
values.push(val);
|
||||
} else if (typeof val === "string") {
|
||||
const parsed = parseFloat(val);
|
||||
if (!isNaN(parsed)) {
|
||||
values.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate an array of numbers using the specified aggregation function
|
||||
*/
|
||||
function aggregateValues(values: number[], aggregation: BigNumberAggregationType): number {
|
||||
if (values.length === 0) return 0;
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
case "avg":
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
case "count":
|
||||
return values.length;
|
||||
case "min":
|
||||
return Math.min(...values);
|
||||
case "max":
|
||||
return Math.max(...values);
|
||||
case "first":
|
||||
return values[0];
|
||||
case "last":
|
||||
return values[values.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the display value and unit suffix for abbreviated display.
|
||||
* Returns the divided-down number (e.g. 1.5 for 1500) and the suffix (e.g. "K"),
|
||||
* along with the appropriate decimal places for formatting.
|
||||
*/
|
||||
function abbreviateValue(value: number): {
|
||||
displayValue: number;
|
||||
unitSuffix?: string;
|
||||
decimalPlaces: number;
|
||||
} {
|
||||
if (Math.abs(value) >= 1_000_000_000) {
|
||||
const v = value / 1_000_000_000;
|
||||
return { displayValue: v, unitSuffix: "B", decimalPlaces: v % 1 === 0 ? 0 : 1 };
|
||||
}
|
||||
if (Math.abs(value) >= 1_000_000) {
|
||||
const v = value / 1_000_000;
|
||||
return { displayValue: v, unitSuffix: "M", decimalPlaces: v % 1 === 0 ? 0 : 1 };
|
||||
}
|
||||
if (Math.abs(value) >= 1_000) {
|
||||
const v = value / 1_000;
|
||||
return { displayValue: v, unitSuffix: "K", decimalPlaces: v % 1 === 0 ? 0 : 1 };
|
||||
}
|
||||
return { displayValue: value, decimalPlaces: getDecimalPlaces(value) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines decimal places for plain (non-abbreviated) display.
|
||||
*/
|
||||
function getDecimalPlaces(value: number): number {
|
||||
if (Number.isInteger(value)) return 0;
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 100) return 0;
|
||||
if (abs >= 10) return 1;
|
||||
if (abs >= 1) return 2;
|
||||
if (abs >= 0.01) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
export function BigNumberCard({ rows, columns, config, isLoading = false }: BigNumberCardProps) {
|
||||
const { column, aggregation, sortDirection, abbreviate = true, prefix, suffix } = config;
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const values = extractColumnValues(rows, column, sortDirection);
|
||||
if (values.length === 0) return null;
|
||||
|
||||
return aggregateValues(values, aggregation);
|
||||
}, [rows, column, aggregation, sortDirection]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center [container-type:size]">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
return <ChartBlankState icon={Hash} message="No data to display" />;
|
||||
}
|
||||
|
||||
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
|
||||
? abbreviateValue(result)
|
||||
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
|
||||
|
||||
return (
|
||||
<div className="h-full w-full [container-type:size]">
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap font-normal tabular-nums leading-none text-text-bright text-[clamp(24px,12cqw,96px)]">
|
||||
{prefix && <span>{prefix}</span>}
|
||||
<AnimatedNumber value={displayValue} decimalPlaces={decimalPlaces} />
|
||||
{(unitSuffix || suffix) && (
|
||||
<span className="text-[0.4em] text-text-dimmed">
|
||||
{unitSuffix}
|
||||
{unitSuffix && suffix ? " " : ""}
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,16 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
|
||||
);
|
||||
};
|
||||
|
||||
const CardHeader = ({ children }: { children: ReactNode }) => {
|
||||
const CardHeader = ({ children, draggable }: { children: ReactNode; draggable?: boolean }) => {
|
||||
return (
|
||||
<Header3 className="mb-3 flex items-center justify-between gap-2 px-3">{children}</Header3>
|
||||
<Header3
|
||||
className={cn(
|
||||
"drag-handle mb-3 flex items-center justify-between gap-2 pl-4 pr-3",
|
||||
draggable && "cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Header3>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -11,20 +11,13 @@ import {
|
||||
type XAxisProps,
|
||||
type YAxisProps,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
type ChartState,
|
||||
} from "~/components/primitives/charts/Chart";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ChartBarLoading, ChartBarInvalid, ChartBarNoData } from "./ChartLoading";
|
||||
import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart";
|
||||
import { useChartContext } from "./ChartContext";
|
||||
import { ChartRoot, useHasNoData } from "./ChartRoot";
|
||||
import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading";
|
||||
import { useHasNoData } from "./ChartRoot";
|
||||
// Legend is now rendered by ChartRoot outside the chart container
|
||||
import { ZoomTooltip, useZoomHandlers } from "./ChartZoom";
|
||||
import { getBarOpacity } from "./hooks/useHighlightState";
|
||||
import type { ZoomRange } from "./hooks/useZoomSelection";
|
||||
|
||||
//TODO: fix the first and last bars in a stack not having rounded corners
|
||||
|
||||
@@ -162,26 +155,27 @@ export function ChartBarRenderer({
|
||||
domain={["auto", (dataMax: number) => dataMax * 1.15]}
|
||||
{...yAxisPropsProp}
|
||||
/>
|
||||
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
|
||||
{!showLegend && (
|
||||
<ChartTooltip
|
||||
cursor={{ fill: "#2C3034" }}
|
||||
content={
|
||||
tooltipLabelFormatter ? (
|
||||
<ChartTooltipContent />
|
||||
) : (
|
||||
<ZoomTooltip
|
||||
isSelecting={zoom?.isSelecting}
|
||||
refAreaLeft={zoom?.refAreaLeft}
|
||||
refAreaRight={zoom?.refAreaRight}
|
||||
invalidSelection={zoom?.invalidSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
allowEscapeViewBox={{ x: false, y: true }}
|
||||
/>
|
||||
)}
|
||||
{/* When legend is shown below the chart, render tooltip with cursor only (no content popup).
|
||||
Otherwise render the full tooltip with zoom instructions. */}
|
||||
<ChartTooltip
|
||||
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
|
||||
content={
|
||||
showLegend ? (
|
||||
() => null
|
||||
) : tooltipLabelFormatter ? (
|
||||
<ChartTooltipContent />
|
||||
) : (
|
||||
<ZoomTooltip
|
||||
isSelecting={zoom?.isSelecting}
|
||||
refAreaLeft={zoom?.refAreaLeft}
|
||||
refAreaRight={zoom?.refAreaRight}
|
||||
invalidSelection={zoom?.invalidSelection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
allowEscapeViewBox={{ x: false, y: true }}
|
||||
/>
|
||||
|
||||
{/* Zoom selection area - rendered before bars to appear behind them */}
|
||||
{enableZoom && zoom?.refAreaLeft !== null && zoom?.refAreaRight !== null && (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Paragraph } from "../Paragraph";
|
||||
|
||||
export function ChartBlankState({
|
||||
icon: Icon,
|
||||
message,
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
message: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex h-full w-full items-center justify-center", className)}>
|
||||
<div className="-mt-3 flex flex-col items-center gap-2">
|
||||
{Icon && <Icon className="size-12 text-charcoal-700" />}
|
||||
<Paragraph variant="small" className="text-text-dimmed/70">
|
||||
{message}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import React, { useMemo } from "react";
|
||||
import type { AggregationType } from "~/components/metrics/QueryWidget";
|
||||
import { useChartContext } from "./ChartContext";
|
||||
import { useSeriesTotal } from "./ChartRoot";
|
||||
import { Button } from "../Buttons";
|
||||
import { Paragraph } from "../Paragraph";
|
||||
import { aggregateValues } from "./aggregation";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
import { SimpleTooltip } from "../Tooltip";
|
||||
|
||||
const aggregationLabels: Record<AggregationType, string> = {
|
||||
sum: "Sum",
|
||||
avg: "Average",
|
||||
count: "Count",
|
||||
min: "Min",
|
||||
max: "Max",
|
||||
};
|
||||
|
||||
export type ChartLegendCompoundProps = {
|
||||
/** Maximum number of legend items to show before collapsing */
|
||||
@@ -13,8 +22,10 @@ export type ChartLegendCompoundProps = {
|
||||
hidden?: boolean;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
/** Label for the total row */
|
||||
/** Label for the total row (derived from aggregation when not provided) */
|
||||
totalLabel?: string;
|
||||
/** Aggregation method – controls the header label and how totals are computed */
|
||||
aggregation?: AggregationType;
|
||||
/** Callback when "View all" button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
@@ -37,57 +48,81 @@ export function ChartLegendCompound({
|
||||
maxItems = Infinity,
|
||||
hidden = false,
|
||||
className,
|
||||
totalLabel = "Total",
|
||||
totalLabel,
|
||||
aggregation,
|
||||
onViewAllLegendItems,
|
||||
scrollable = false,
|
||||
}: ChartLegendCompoundProps) {
|
||||
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
|
||||
const totals = useSeriesTotal();
|
||||
const totals = useSeriesTotal(aggregation);
|
||||
|
||||
// Calculate grand total (sum of all series totals)
|
||||
// Derive the effective label from the aggregation type when no explicit label is provided
|
||||
const effectiveTotalLabel = totalLabel ?? (aggregation ? aggregationLabels[aggregation] : "Total");
|
||||
|
||||
// Calculate grand total by aggregating across all per-series values
|
||||
const grandTotal = useMemo(() => {
|
||||
return dataKeys.reduce((sum, key) => sum + (totals[key] || 0), 0);
|
||||
}, [totals, dataKeys]);
|
||||
const values = dataKeys.map((key) => totals[key] || 0);
|
||||
if (!aggregation) {
|
||||
// Default: sum
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
return aggregateValues(values, aggregation);
|
||||
}, [totals, dataKeys, aggregation]);
|
||||
|
||||
// Calculate current total based on hover state
|
||||
const currentTotal = useMemo(() => {
|
||||
// Calculate current total based on hover state (null when hovering a gap-filled point)
|
||||
const currentTotal = useMemo((): number | null => {
|
||||
if (!highlight.activePayload?.length) return grandTotal;
|
||||
|
||||
// Sum all values from the hovered data point
|
||||
return highlight.activePayload.reduce((sum, item) => {
|
||||
if (item.value !== undefined && dataKeys.includes(item.dataKey as string)) {
|
||||
return sum + (Number(item.value) || 0);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
}, [highlight.activePayload, grandTotal, dataKeys]);
|
||||
// Collect all series values from the hovered data point, preserving nulls
|
||||
const rawValues = highlight.activePayload
|
||||
.filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string))
|
||||
.map((item) => item.value);
|
||||
|
||||
// Get the label for the total row - x-axis value when hovering, totalLabel otherwise
|
||||
// Filter to non-null values only
|
||||
const values = rawValues
|
||||
.filter((v): v is number => v != null)
|
||||
.map((v) => Number(v) || 0);
|
||||
|
||||
// All null → gap-filled point, return null to show dash
|
||||
if (values.length === 0) return null;
|
||||
|
||||
if (!aggregation) {
|
||||
// Default: sum
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
return aggregateValues(values, aggregation);
|
||||
}, [highlight.activePayload, grandTotal, dataKeys, aggregation]);
|
||||
|
||||
// Get the label for the total row - x-axis value when hovering, effectiveTotalLabel otherwise
|
||||
const currentTotalLabel = useMemo(() => {
|
||||
if (!highlight.activePayload?.length) return totalLabel;
|
||||
if (!highlight.activePayload?.length) return effectiveTotalLabel;
|
||||
|
||||
// Get the x-axis label from the payload's original data
|
||||
const firstPayloadItem = highlight.activePayload[0];
|
||||
const xAxisValue = firstPayloadItem?.payload?.[dataKey];
|
||||
|
||||
if (xAxisValue === undefined) return totalLabel;
|
||||
if (xAxisValue === undefined) return effectiveTotalLabel;
|
||||
|
||||
// Apply the formatter if provided, otherwise just stringify the value
|
||||
const stringValue = String(xAxisValue);
|
||||
return labelFormatter ? labelFormatter(stringValue) : stringValue;
|
||||
}, [highlight.activePayload, dataKey, totalLabel, labelFormatter]);
|
||||
}, [highlight.activePayload, dataKey, effectiveTotalLabel, labelFormatter]);
|
||||
|
||||
// Get current data for the legend based on hover state
|
||||
const currentData = useMemo(() => {
|
||||
// Get current data for the legend based on hover state (values may be null for gap-filled points)
|
||||
const currentData = useMemo((): Record<string, number | null> => {
|
||||
if (!highlight.activePayload?.length) return totals;
|
||||
|
||||
// If we have activePayload data from hovering over a bar
|
||||
const hoverData = highlight.activePayload.reduce((acc, item) => {
|
||||
if (item.dataKey && item.value !== undefined) {
|
||||
acc[item.dataKey] = Number(item.value) || 0;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
// If we have activePayload data from hovering over a bar/line
|
||||
const hoverData = highlight.activePayload.reduce(
|
||||
(acc, item) => {
|
||||
if (item.dataKey && item.value !== undefined) {
|
||||
// Preserve null for gap-filled points instead of coercing to 0
|
||||
acc[item.dataKey] = item.value != null ? Number(item.value) || 0 : null;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number | null>
|
||||
);
|
||||
|
||||
// Return a merged object - totals for keys not in the hover data
|
||||
return {
|
||||
@@ -132,11 +167,7 @@ export function ChartLegendCompound({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col pt-4 text-sm",
|
||||
scrollable && "max-h-[50%] min-h-0",
|
||||
className
|
||||
)}
|
||||
className={cn("flex flex-col px-2 pb-2 pt-4 text-sm", scrollable && "max-h-[50%] min-h-0", className)}
|
||||
>
|
||||
{/* Total row */}
|
||||
<div
|
||||
@@ -147,7 +178,11 @@ export function ChartLegendCompound({
|
||||
>
|
||||
<span className="font-medium">{currentTotalLabel}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
<AnimatedNumber value={currentTotal} duration={0.25} />
|
||||
{currentTotal != null ? (
|
||||
<AnimatedNumber value={currentTotal} duration={0.25} />
|
||||
) : (
|
||||
"\u2013"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -155,17 +190,23 @@ export function ChartLegendCompound({
|
||||
<div className="mx-2 my-1 shrink-0 border-t border-charcoal-750" />
|
||||
|
||||
{/* Legend items - scrollable when scrollable prop is true */}
|
||||
<div className={cn("flex flex-col", scrollable && "min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
scrollable &&
|
||||
"min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
)}
|
||||
>
|
||||
{legendItems.visible.map((item) => {
|
||||
const total = currentData[item.dataKey] ?? 0;
|
||||
const total = currentData[item.dataKey] ?? null;
|
||||
const isActive = highlight.activeBarKey === item.dataKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
total === 0 && "opacity-50"
|
||||
"relative flex w-full cursor-default items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
(total == null || total === 0) && "opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
|
||||
onMouseLeave={() => highlight.reset()}
|
||||
@@ -177,25 +218,43 @@ export function ChartLegendCompound({
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="w-1 shrink-0 self-stretch rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="w-1 shrink-0 self-stretch rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
content={item.label}
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
className="max-w-xs break-words"
|
||||
buttonClassName="cursor-default min-w-0"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"self-start tabular-nums",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
{total != null ? (
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
) : (
|
||||
"\u2013"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,11 +266,14 @@ export function ChartLegendCompound({
|
||||
(legendItems.hoveredHiddenItem ? (
|
||||
<HoveredHiddenItemRow
|
||||
item={legendItems.hoveredHiddenItem}
|
||||
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
|
||||
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? null}
|
||||
remainingCount={legendItems.remaining - 1}
|
||||
/>
|
||||
) : (
|
||||
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
|
||||
<ViewAllDataRow
|
||||
remainingCount={legendItems.remaining}
|
||||
onViewAll={onViewAllLegendItems}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,29 +287,32 @@ type ViewAllDataRowProps = {
|
||||
|
||||
function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
fullWidth
|
||||
iconSpacing="justify-between"
|
||||
className="px-2 py-1"
|
||||
<div
|
||||
className="relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition hover:bg-charcoal-850"
|
||||
onClick={onViewAll}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onViewAll?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-text-dimmed">
|
||||
<div className="h-3 w-1 rounded-[2px] border border-charcoal-600" />
|
||||
<Paragraph variant="extra-small" className="tabular-nums">
|
||||
{remainingCount} more…
|
||||
</Paragraph>
|
||||
<div className="relative flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1 shrink-0 self-stretch rounded-[2px] border border-charcoal-600" />
|
||||
<span className="text-text-dimmed tabular-nums">{remainingCount} more…</span>
|
||||
</div>
|
||||
<span className="self-start text-indigo-500">View all</span>
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-indigo-500">
|
||||
View all
|
||||
</Paragraph>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HoveredHiddenItemRowProps = {
|
||||
item: { dataKey: string; color?: string; label: React.ReactNode };
|
||||
value: number;
|
||||
value: number | null;
|
||||
remainingCount: number;
|
||||
};
|
||||
|
||||
@@ -261,11 +326,11 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-2">
|
||||
<div className="relative flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="h-3 w-1 shrink-0 rounded-[2px]"
|
||||
className="w-1 shrink-0 self-stretch rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
@@ -273,7 +338,7 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem
|
||||
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
|
||||
</div>
|
||||
<span className="tabular-nums text-text-bright">
|
||||
<AnimatedNumber value={value} duration={0.25} />
|
||||
{value != null ? <AnimatedNumber value={value} duration={0.25} /> : "\u2013"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,14 +155,12 @@ export function ChartLineRenderer({
|
||||
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
|
||||
<XAxis {...xAxisConfig} />
|
||||
<YAxis {...yAxisConfig} />
|
||||
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
|
||||
{!showLegend && (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
)}
|
||||
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
|
||||
<ChartTooltip
|
||||
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
|
||||
content={showLegend ? () => null : <ChartTooltipContent indicator="line" />}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
{dataKeys.map((key) => (
|
||||
<Area
|
||||
@@ -188,6 +186,7 @@ export function ChartLineRenderer({
|
||||
width={width}
|
||||
height={height}
|
||||
margin={{
|
||||
top: 5,
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
@@ -205,14 +204,12 @@ export function ChartLineRenderer({
|
||||
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
|
||||
<XAxis {...xAxisConfig} />
|
||||
<YAxis {...yAxisConfig} />
|
||||
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
|
||||
{!showLegend && (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent />}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
)}
|
||||
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
|
||||
<ChartTooltip
|
||||
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
|
||||
content={showLegend ? () => null : <ChartTooltipContent />}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
{dataKeys.map((key) => (
|
||||
<Line
|
||||
@@ -221,7 +218,7 @@ export function ChartLineRenderer({
|
||||
type={lineType}
|
||||
stroke={config[key]?.color}
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
dot={{ r: 1.5, fill: config[key]?.color, strokeWidth: 0 }}
|
||||
activeDot={{ r: 4 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from "react";
|
||||
import type * as RechartsPrimitive from "recharts";
|
||||
import type { AggregationType } from "~/components/metrics/QueryWidget";
|
||||
import { ChartContainer, type ChartConfig, type ChartState } from "./Chart";
|
||||
import { ChartProvider, useChartContext, type LabelFormatter } from "./ChartContext";
|
||||
import { ChartLegendCompound } from "./ChartLegendCompound";
|
||||
@@ -29,6 +30,8 @@ export type ChartRootProps = {
|
||||
maxLegendItems?: number;
|
||||
/** Label for the total row in the legend */
|
||||
legendTotalLabel?: string;
|
||||
/** Aggregation method used by the legend to compute totals (defaults to sum behavior) */
|
||||
legendAggregation?: AggregationType;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
@@ -73,6 +76,7 @@ export function ChartRoot({
|
||||
showLegend = false,
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
legendAggregation,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
@@ -96,6 +100,7 @@ export function ChartRoot({
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={maxLegendItems}
|
||||
legendTotalLabel={legendTotalLabel}
|
||||
legendAggregation={legendAggregation}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
fillContainer={fillContainer}
|
||||
@@ -112,6 +117,7 @@ type ChartRootInnerProps = {
|
||||
showLegend?: boolean;
|
||||
maxLegendItems?: number;
|
||||
legendTotalLabel?: string;
|
||||
legendAggregation?: AggregationType;
|
||||
onViewAllLegendItems?: () => void;
|
||||
legendScrollable?: boolean;
|
||||
fillContainer?: boolean;
|
||||
@@ -124,6 +130,7 @@ function ChartRootInner({
|
||||
showLegend = false,
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
legendAggregation,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
@@ -165,6 +172,7 @@ function ChartRootInner({
|
||||
<ChartLegendCompound
|
||||
maxItems={maxLegendItems}
|
||||
totalLabel={legendTotalLabel}
|
||||
aggregation={legendAggregation}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
scrollable={legendScrollable}
|
||||
/>
|
||||
@@ -194,18 +202,79 @@ export function useHasNoData(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate totals for each series across all data points.
|
||||
* Hook to calculate aggregated values for each series across all data points.
|
||||
* When no aggregation is provided, defaults to sum (original behavior).
|
||||
* Useful for legend displays.
|
||||
*/
|
||||
export function useSeriesTotal(): Record<string, number> {
|
||||
export function useSeriesTotal(aggregation?: AggregationType): Record<string, number> {
|
||||
const { data, dataKeys } = useChartContext();
|
||||
|
||||
return useMemo(() => {
|
||||
return data.reduce((acc, item) => {
|
||||
for (const seriesKey of dataKeys) {
|
||||
acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0);
|
||||
// Sum (default) and count use additive accumulation
|
||||
if (!aggregation || aggregation === "sum" || aggregation === "count") {
|
||||
return data.reduce(
|
||||
(acc, item) => {
|
||||
for (const seriesKey of dataKeys) {
|
||||
acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
}
|
||||
|
||||
if (aggregation === "avg") {
|
||||
const sums: Record<string, number> = {};
|
||||
const counts: Record<string, number> = {};
|
||||
for (const item of data) {
|
||||
for (const seriesKey of dataKeys) {
|
||||
const rawVal = item[seriesKey];
|
||||
if (rawVal == null) continue; // skip gap-filled nulls
|
||||
const val = Number(rawVal);
|
||||
sums[seriesKey] = (sums[seriesKey] || 0) + val;
|
||||
counts[seriesKey] = (counts[seriesKey] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}, [data, dataKeys]);
|
||||
const result: Record<string, number> = {};
|
||||
for (const key of dataKeys) {
|
||||
result[key] = counts[key] ? sums[key]! / counts[key]! : 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (aggregation === "min") {
|
||||
const result: Record<string, number> = {};
|
||||
for (const item of data) {
|
||||
for (const seriesKey of dataKeys) {
|
||||
if (item[seriesKey] == null) continue; // skip gap-filled nulls
|
||||
const val = Number(item[seriesKey]);
|
||||
if (result[seriesKey] === undefined || val < result[seriesKey]) {
|
||||
result[seriesKey] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to 0 for series with no data
|
||||
for (const key of dataKeys) {
|
||||
if (result[key] === undefined) result[key] = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// aggregation === "max"
|
||||
const result: Record<string, number> = {};
|
||||
for (const item of data) {
|
||||
for (const seriesKey of dataKeys) {
|
||||
if (item[seriesKey] == null) continue; // skip gap-filled nulls
|
||||
const val = Number(item[seriesKey]);
|
||||
if (result[seriesKey] === undefined || val > result[seriesKey]) {
|
||||
result[seriesKey] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to 0 for series with no data
|
||||
for (const key of dataKeys) {
|
||||
if (result[key] === undefined) result[key] = 0;
|
||||
}
|
||||
return result;
|
||||
}, [data, dataKeys, aggregation]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AggregationType } from "~/components/metrics/QueryWidget";
|
||||
|
||||
/**
|
||||
* Aggregate an array of numbers using the specified aggregation function.
|
||||
*
|
||||
* Shared utility so both QueryResultsChart (data transformation) and chart
|
||||
* legend components can reuse the same logic without circular imports.
|
||||
*/
|
||||
export function aggregateValues(values: number[], aggregation: AggregationType): number {
|
||||
if (values.length === 0) return 0;
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
case "avg":
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
case "count":
|
||||
return values.length;
|
||||
case "min":
|
||||
return Math.min(...values);
|
||||
case "max":
|
||||
return Math.max(...values);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,13 @@ export function PacketDisplay({
|
||||
dataType,
|
||||
title,
|
||||
searchTerm,
|
||||
wrap,
|
||||
}: {
|
||||
data: string;
|
||||
dataType: string;
|
||||
title: string;
|
||||
searchTerm?: string;
|
||||
wrap?: boolean;
|
||||
}) {
|
||||
switch (dataType) {
|
||||
case "application/store": {
|
||||
@@ -54,6 +56,7 @@ export function PacketDisplay({
|
||||
showLineNumbers={false}
|
||||
showTextWrapping
|
||||
searchTerm={searchTerm}
|
||||
wrap={wrap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +70,7 @@ export function PacketDisplay({
|
||||
showLineNumbers={false}
|
||||
showTextWrapping
|
||||
searchTerm={searchTerm}
|
||||
wrap={wrap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ function ReplayForm({
|
||||
tags,
|
||||
version,
|
||||
machine,
|
||||
region,
|
||||
prioritySeconds,
|
||||
},
|
||||
] = useForm({
|
||||
@@ -357,6 +358,35 @@ function ReplayForm({
|
||||
)}
|
||||
<FormError id={version.errorId}>{version.error}</FormError>
|
||||
</InputGroup>
|
||||
{replayData.regions.length > 1 && (
|
||||
<InputGroup>
|
||||
<Label htmlFor={region.id} variant="small">
|
||||
Region
|
||||
</Label>
|
||||
<Select
|
||||
{...conform.select(region)}
|
||||
variant="tertiary/small"
|
||||
placeholder={replayData.disableVersionSelection ? "–" : undefined}
|
||||
dropdownIcon
|
||||
items={replayData.regions}
|
||||
defaultValue={replayData.region ?? undefined}
|
||||
disabled={replayData.disableVersionSelection}
|
||||
>
|
||||
{replayData.regions.map((r) => (
|
||||
<SelectItem key={r.name} value={r.name}>
|
||||
{r.description ? `${r.name} — ${r.description}` : r.name}
|
||||
{r.isDefault ? " (default)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
{replayData.disableVersionSelection ? (
|
||||
<Hint>Region is not available in the development environment.</Hint>
|
||||
) : (
|
||||
<Hint>Overrides the region for this run.</Hint>
|
||||
)}
|
||||
<FormError id={region.errorId}>{region.error}</FormError>
|
||||
</InputGroup>
|
||||
)}
|
||||
<InputGroup>
|
||||
<Label htmlFor={queue.id} variant="small">
|
||||
Queue
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
InformationCircleIcon,
|
||||
RectangleStackIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
TagIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
|
||||
@@ -47,8 +48,6 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
) {
|
||||
return <TablerIcon name={spanNameIcon.iconName} className={className} />;
|
||||
}
|
||||
|
||||
<InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
|
||||
}
|
||||
|
||||
if (!name) return <Squares2X2Icon className={cn(className, "text-text-dimmed")} />;
|
||||
@@ -81,6 +80,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
return <WaitpointTokenIcon className={cn(className, "text-sky-500")} />;
|
||||
case "function":
|
||||
return <FunctionIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "query":
|
||||
return <TableCellsIcon className={cn(className, "text-query")} />;
|
||||
//log levels
|
||||
case "debug":
|
||||
case "log":
|
||||
@@ -110,7 +111,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
case "task-hook-catchError":
|
||||
return <FunctionIcon className={cn(className, "text-error")} />;
|
||||
case "streams":
|
||||
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
|
||||
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
|
||||
}
|
||||
|
||||
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subDays,
|
||||
subWeeks
|
||||
subWeeks,
|
||||
} from "date-fns";
|
||||
import parse from "parse-duration";
|
||||
import { startTransition, useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
@@ -105,7 +105,7 @@ const timePeriods = [
|
||||
{
|
||||
label: "30 days",
|
||||
value: "30d",
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const timeUnits = [
|
||||
@@ -214,6 +214,48 @@ export const timeFilters = ({
|
||||
};
|
||||
};
|
||||
|
||||
export function timeFilterFromTo(props: {
|
||||
period?: string;
|
||||
from?: string | number;
|
||||
to?: string | number;
|
||||
defaultPeriod: string;
|
||||
}): { from: Date; to: Date; isDefault: boolean } {
|
||||
const time = timeFilters(props);
|
||||
|
||||
const periodMs = time.period ? parse(time.period) : undefined;
|
||||
|
||||
if (periodMs) {
|
||||
return {
|
||||
from: new Date(Date.now() - periodMs),
|
||||
to: new Date(),
|
||||
isDefault: time.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
if (time.from && time.to) {
|
||||
return {
|
||||
from: time.from,
|
||||
to: time.to,
|
||||
isDefault: time.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
if (time.from) {
|
||||
return {
|
||||
from: time.from,
|
||||
to: new Date(),
|
||||
isDefault: time.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultPeriodMs = parse(props.defaultPeriod) ?? 24 * 60 * 60 * 1_000;
|
||||
return {
|
||||
from: new Date(Date.now() - defaultPeriodMs),
|
||||
to: time.to ?? new Date(),
|
||||
isDefault: time.isDefault,
|
||||
};
|
||||
}
|
||||
|
||||
export function timeFilterRenderValues({
|
||||
from,
|
||||
to,
|
||||
@@ -257,7 +299,12 @@ export function timeFilterRenderValues({
|
||||
case "range":
|
||||
{
|
||||
//If the day is the same, only show the time for the `to` date
|
||||
const isSameDay = from && to && from.getDate() === to.getDate() && from.getMonth() === to.getMonth() && from.getFullYear() === to.getFullYear();
|
||||
const isSameDay =
|
||||
from &&
|
||||
to &&
|
||||
from.getDate() === to.getDate() &&
|
||||
from.getMonth() === to.getMonth() &&
|
||||
from.getFullYear() === to.getFullYear();
|
||||
|
||||
valueLabel = (
|
||||
<span>
|
||||
@@ -279,8 +326,8 @@ export function timeFilterRenderValues({
|
||||
rangeType === "range" || rangeType === "period"
|
||||
? labelName
|
||||
: rangeType === "from"
|
||||
? `${labelName} after`
|
||||
: `${labelName} before`;
|
||||
? `${labelName} after`
|
||||
: `${labelName} before`;
|
||||
|
||||
return { label, valueLabel, rangeType };
|
||||
}
|
||||
@@ -305,6 +352,8 @@ export interface TimeFilterProps {
|
||||
onValueChange?: (values: TimeFilterApplyValues) => void;
|
||||
/** When set an upgrade message will be shown if you select a period further back than this number of days */
|
||||
maxPeriodDays?: number;
|
||||
/** Optional className override for the value text in the filter pill */
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
export function TimeFilter({
|
||||
@@ -317,6 +366,7 @@ export function TimeFilter({
|
||||
applyShortcut,
|
||||
onValueChange,
|
||||
maxPeriodDays,
|
||||
valueClassName,
|
||||
}: TimeFilterProps = {}) {
|
||||
const { value } = useSearchParams();
|
||||
const periodValue = period ?? value("period");
|
||||
@@ -343,6 +393,7 @@ export function TimeFilter({
|
||||
value={constrained.valueLabel}
|
||||
removable={false}
|
||||
variant="secondary/small"
|
||||
valueClassName={valueClassName}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
@@ -443,7 +494,8 @@ export function TimeDropdown({
|
||||
if (!maxPeriodDays) return false;
|
||||
|
||||
if (activeSection === "duration") {
|
||||
const periodToCheck = selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
|
||||
const periodToCheck =
|
||||
selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
|
||||
if (!periodToCheck) return false;
|
||||
return periodToDays(periodToCheck) > maxPeriodDays;
|
||||
} else {
|
||||
@@ -452,33 +504,26 @@ export function TimeDropdown({
|
||||
}
|
||||
})();
|
||||
|
||||
const applySelection = useCallback(() => {
|
||||
setValidationError(null);
|
||||
const applyPeriod = useCallback(
|
||||
(periodToApply: string) => {
|
||||
setValidationError(null);
|
||||
|
||||
if (exceedsMaxPeriod) {
|
||||
setValidationError(`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSection === "duration") {
|
||||
// Validate custom duration
|
||||
if (selectedPeriod === "custom" && !isCustomDurationValid) {
|
||||
setValidationError("Please enter a valid custom duration");
|
||||
if (maxPeriodDays && periodToDays(periodToApply) > maxPeriodDays) {
|
||||
setValidationError(
|
||||
`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let periodToApply = selectedPeriod;
|
||||
if (selectedPeriod === "custom") {
|
||||
periodToApply = `${customValue}${customUnit}`;
|
||||
}
|
||||
|
||||
const values: TimeFilterApplyValues = { period: periodToApply, from: undefined, to: undefined };
|
||||
const values: TimeFilterApplyValues = {
|
||||
period: periodToApply,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
};
|
||||
|
||||
if (onValueChange) {
|
||||
// Controlled mode - just call the handler
|
||||
onValueChange(values);
|
||||
} else {
|
||||
// URL mode - navigate
|
||||
replace({
|
||||
period: periodToApply,
|
||||
cursor: undefined,
|
||||
@@ -492,6 +537,30 @@ export function TimeDropdown({
|
||||
setToValue(undefined);
|
||||
setOpen(false);
|
||||
onApply?.(values);
|
||||
},
|
||||
[maxPeriodDays, onValueChange, replace, onApply]
|
||||
);
|
||||
|
||||
const applySelection = useCallback(() => {
|
||||
setValidationError(null);
|
||||
|
||||
if (exceedsMaxPeriod) {
|
||||
setValidationError(
|
||||
`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSection === "duration") {
|
||||
// Validate custom duration
|
||||
if (selectedPeriod === "custom" && !isCustomDurationValid) {
|
||||
setValidationError("Please enter a valid custom duration");
|
||||
return;
|
||||
}
|
||||
|
||||
const periodToApply =
|
||||
selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
|
||||
applyPeriod(periodToApply);
|
||||
} else {
|
||||
// Validate date range
|
||||
if (!fromValue && !toValue) {
|
||||
@@ -538,7 +607,8 @@ export function TimeDropdown({
|
||||
onApply,
|
||||
onValueChange,
|
||||
exceedsMaxPeriod,
|
||||
maxPeriodDays
|
||||
maxPeriodDays,
|
||||
applyPeriod,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -579,9 +649,9 @@ export function TimeDropdown({
|
||||
? "border-indigo-500 "
|
||||
: "border-charcoal-650 hover:border-charcoal-600",
|
||||
validationError &&
|
||||
activeSection === "duration" &&
|
||||
selectedPeriod === "custom" &&
|
||||
"border-error"
|
||||
activeSection === "duration" &&
|
||||
selectedPeriod === "custom" &&
|
||||
"border-error"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -649,7 +719,7 @@ export function TimeDropdown({
|
||||
setCustomValue(parsed.value.toString());
|
||||
setCustomUnit(parsed.unit);
|
||||
}
|
||||
setValidationError(null);
|
||||
applyPeriod(p.value);
|
||||
}}
|
||||
fullWidth
|
||||
type="button"
|
||||
@@ -800,7 +870,14 @@ export function TimeDropdown({
|
||||
{exceedsMaxPeriod && organization && (
|
||||
<Callout
|
||||
variant="pricing"
|
||||
cta={<LinkButton variant="primary/small" to={organizationBillingPath({ slug: organization.slug })}>Upgrade</LinkButton>}
|
||||
cta={
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
to={organizationBillingPath({ slug: organization.slug })}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
}
|
||||
className="items-center"
|
||||
>
|
||||
{simplur`Your plan allows a maximum of ${maxPeriodDays} day[|s].`}
|
||||
@@ -810,7 +887,7 @@ export function TimeDropdown({
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-between gap-1 border-t border-grid-bright px-0 pt-3">
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFromValue(from);
|
||||
@@ -824,11 +901,15 @@ export function TimeDropdown({
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
shortcut={applyShortcut ? applyShortcut : {
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
shortcut={
|
||||
applyShortcut
|
||||
? applyShortcut
|
||||
: {
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
applySelection();
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3";
|
||||
import assertNever from "assert-never";
|
||||
import { HourglassIcon } from "lucide-react";
|
||||
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
|
||||
@@ -248,26 +249,9 @@ export function runStatusFromFriendlyTitle(friendly: RunFriendlyStatus): TaskRun
|
||||
return result[0] as TaskRunStatus;
|
||||
}
|
||||
|
||||
export const runFriendlyStatus = [
|
||||
"Delayed",
|
||||
"Queued",
|
||||
"Pending version",
|
||||
"Dequeued",
|
||||
"Executing",
|
||||
"Waiting",
|
||||
"Reattempting",
|
||||
"Paused",
|
||||
"Canceled",
|
||||
"Interrupted",
|
||||
"Completed",
|
||||
"Failed",
|
||||
"System failure",
|
||||
"Crashed",
|
||||
"Expired",
|
||||
"Timed out",
|
||||
] as const;
|
||||
|
||||
export type RunFriendlyStatus = (typeof runFriendlyStatus)[number];
|
||||
// runFriendlyStatus and RunFriendlyStatus are imported from @trigger.dev/core/v3
|
||||
// and re-exported here for backward compatibility.
|
||||
export { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3";
|
||||
|
||||
/**
|
||||
* Check if a value is a valid TaskRunStatus
|
||||
@@ -304,3 +288,42 @@ export const runStatusTitleFromStatus: Record<TaskRunStatus, RunFriendlyStatus>
|
||||
};
|
||||
|
||||
const titlesStatusesArray = Object.entries(runStatusTitleFromStatus);
|
||||
|
||||
/**
|
||||
* Hex color for each TaskRunStatus, mirroring `runStatusClassNameColor` but as
|
||||
* concrete hex values for non-CSS contexts (e.g. chart series colors).
|
||||
*/
|
||||
const RUN_STATUS_HEX_COLORS: Record<TaskRunStatus, string> = {
|
||||
PENDING: "#5F6570", // charcoal-500
|
||||
DELAYED: "#6B7580", // charcoal ~450
|
||||
PENDING_VERSION: "#f59e0b", // amber-500
|
||||
WAITING_FOR_DEPLOY: "#d97706", // amber-600
|
||||
EXECUTING: "#3b82f6", // blue-500
|
||||
RETRYING_AFTER_FAILURE: "#2f6fec", // blue ~550
|
||||
DEQUEUED: "#4D8EF5", // blue ~475
|
||||
WAITING_TO_RESUME: "#555D67", // charcoal ~550
|
||||
PAUSED: "#fbbf24", // amber-400
|
||||
CANCELED: "#78828C", // charcoal ~400
|
||||
EXPIRED: "#848D96", // charcoal ~350
|
||||
INTERRUPTED: "#D52C4D", // rose — evenly spaced (error)
|
||||
COMPLETED_SUCCESSFULLY: "#28BF5C", // mint-500 (success)
|
||||
COMPLETED_WITH_ERRORS: "#DE405C", // rose — evenly spaced (error)
|
||||
SYSTEM_FAILURE: "#E7536C", // rose — evenly spaced (error)
|
||||
CRASHED: "#cc193d", // rose — darkest (error)
|
||||
TIMED_OUT: "#F0667B", // rose — lightest (error)
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the hex color for a run status value. Accepts either a raw TaskRunStatus
|
||||
* (e.g. "COMPLETED_SUCCESSFULLY") or a friendly name (e.g. "Completed").
|
||||
* Returns `undefined` when the value is not a recognised status.
|
||||
*/
|
||||
export function getRunStatusHexColor(value: string): string | undefined {
|
||||
if (isTaskRunStatus(value)) {
|
||||
return RUN_STATUS_HEX_COLORS[value];
|
||||
}
|
||||
if (isRunFriendlyStatus(value)) {
|
||||
return RUN_STATUS_HEX_COLORS[runStatusFromFriendlyTitle(value)];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1180,7 +1180,7 @@ const EnvironmentSchema = z
|
||||
CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||||
CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||||
|
||||
// Logs List Query Settings (for paginated log views)
|
||||
// Logs Query Settings
|
||||
CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_000_000_000),
|
||||
CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT: z.coerce
|
||||
.number()
|
||||
@@ -1190,15 +1190,20 @@ const EnvironmentSchema = z
|
||||
CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ: z.coerce.number().int().default(10_000_000),
|
||||
CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME: z.coerce.number().int().default(120),
|
||||
|
||||
// Logs Detail Query Settings (for single log views)
|
||||
CLICKHOUSE_LOGS_DETAIL_MAX_MEMORY_USAGE: z.coerce.number().int().default(64_000_000),
|
||||
CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2),
|
||||
CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60),
|
||||
|
||||
// Query feature flag
|
||||
QUERY_FEATURE_ENABLED: z.string().default("1"),
|
||||
|
||||
// Logs page ClickHouse URL (for logs queries)
|
||||
LOGS_CLICKHOUSE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
|
||||
|
||||
// Query page ClickHouse limits (for TSQL queries)
|
||||
QUERY_CLICKHOUSE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
|
||||
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
|
||||
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
|
||||
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
|
||||
@@ -1208,7 +1213,10 @@ const EnvironmentSchema = z
|
||||
|
||||
// Query page concurrency limits
|
||||
QUERY_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(3),
|
||||
QUERY_GLOBAL_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
|
||||
QUERY_GLOBAL_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
|
||||
|
||||
// Metric widget concurrency limits
|
||||
METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(30),
|
||||
|
||||
EVENTS_CLICKHOUSE_URL: z
|
||||
.string()
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+9
-5
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { AIQueryInput } from "~/components/code/AIQueryInput";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import type { AITimeFilter } from "./types";
|
||||
|
||||
export function AITabContent({
|
||||
@@ -41,8 +43,8 @@ export function AITabContent({
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<Header3 className="mb-2 text-text-bright">Example prompts</Header3>
|
||||
<div className="space-y-2">
|
||||
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examplePrompts.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
@@ -53,9 +55,12 @@ export function AITabContent({
|
||||
key: (prev?.key ?? 0) + 1,
|
||||
}));
|
||||
}}
|
||||
className="block w-full rounded-md border border-grid-dimmed bg-charcoal-800 px-3 py-2 text-left text-sm text-text-dimmed transition-colors hover:border-grid-bright hover:bg-charcoal-750 hover:text-text-bright"
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500 focus-custom focus-visible:!rounded-full"
|
||||
>
|
||||
{example}
|
||||
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
|
||||
<Paragraph variant="small" className="text-left transition group-hover:text-text-bright">
|
||||
{example}
|
||||
</Paragraph>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -63,4 +68,3 @@ export function AITabContent({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -38,6 +38,19 @@ ORDER BY p50_duration_ms DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
},
|
||||
{
|
||||
title: "Runs over time",
|
||||
description:
|
||||
"Count of runs bucketed over time. The bucket size adjusts automatically to the time range.",
|
||||
query: `SELECT
|
||||
timeBucket(),
|
||||
count() AS run_count
|
||||
FROM runs
|
||||
GROUP BY timeBucket
|
||||
ORDER BY timeBucket
|
||||
LIMIT 1000`,
|
||||
scope: "environment",
|
||||
},
|
||||
{
|
||||
title: "Most expensive 100 runs (past 7d)",
|
||||
description: "Top 100 runs by cost over the last 7 days.",
|
||||
|
||||
+31
-20
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { Popover, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
|
||||
import { timeFilterRenderValues } from "~/components/runs/v3/SharedFilters";
|
||||
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
@@ -91,23 +94,32 @@ export function QueryHistoryPopover({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ClockRotateLeftIcon}
|
||||
leadingIconClassName="-mr-1.5"
|
||||
TrailingIcon={ChevronUpDownIcon}
|
||||
disabled={history.length === 0}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] min-w-0 overflow-hidden p-0"
|
||||
<PopoverPrimitive.Content
|
||||
className={cn(
|
||||
"z-50 w-[400px] min-w-0 overflow-hidden rounded border border-charcoal-700 bg-background-bright p-0 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
)}
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="max-h-[40rem] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="p-1">
|
||||
{history.map((item) => {
|
||||
// Format time filter display
|
||||
const { valueLabel } = timeFilterRenderValues({ period: item.filterPeriod ?? undefined, from: item.filterFrom ?? undefined, to: item.filterTo ?? undefined });
|
||||
const { valueLabel } = timeFilterRenderValues({
|
||||
period: item.filterPeriod ?? undefined,
|
||||
from: item.filterFrom ?? undefined,
|
||||
to: item.filterTo ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -117,21 +129,16 @@ export function QueryHistoryPopover({
|
||||
onQuerySelected(item);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900"
|
||||
className="flex w-full flex-col gap-1 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-750"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-start gap-0.5 overflow-hidden">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
{item.title ? (
|
||||
<>
|
||||
<p className="w-full truncate text-left text-sm font-medium text-text-bright">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
</>
|
||||
<p className="mb-1 truncate text-left text-sm font-medium text-text-bright">
|
||||
{item.title}
|
||||
</p>
|
||||
) : (
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-[#9b99ff]">
|
||||
{highlightSQL(item.query)}
|
||||
<p className="mb-1 truncate text-left font-mono text-xs text-text-bright">
|
||||
{item.query.split("\n")[0].slice(0, 60)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
|
||||
@@ -140,13 +147,17 @@ export function QueryHistoryPopover({
|
||||
{item.userName && <span>· {item.userName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full border-l-2 border-charcoal-600 pl-2.5">
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPrimitive.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -488,6 +488,11 @@ ORDER BY run_count DESC`,
|
||||
<FunctionCategory
|
||||
title="Date/time functions"
|
||||
functions={[
|
||||
{
|
||||
name: "timeBucket()",
|
||||
desc: "Auto-bucket by time period. Uses the table's time column with an interval based on the query's time range.",
|
||||
example: "SELECT timeBucket(), count() FROM runs GROUP BY timeBucket",
|
||||
},
|
||||
{ name: "now()", desc: "Current date and time", example: "now()" },
|
||||
{ name: "today()", desc: "Current date", example: "today()" },
|
||||
{ name: "yesterday()", desc: "Yesterday's date", example: "yesterday()" },
|
||||
|
||||
+45
-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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
contentHash: body.data.contentHash,
|
||||
git: body.data.gitMeta,
|
||||
runtime: body.data.runtime,
|
||||
buildServerMetadata: body.data.buildServerMetadata,
|
||||
})
|
||||
.match(
|
||||
() => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { QueryError } from "@internal/clickhouse";
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { executeQuery, type QueryScope } from "~/services/queryService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { rowsToCSV } from "~/utils/dataExport";
|
||||
|
||||
const BodySchema = z.object({
|
||||
query: z.string(),
|
||||
scope: z.enum(["organization", "project", "environment"]).default("environment"),
|
||||
period: z.string().nullish(),
|
||||
from: z.string().nullish(),
|
||||
to: z.string().nullish(),
|
||||
format: z.enum(["json", "csv"]).default("json"),
|
||||
});
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
body: BodySchema,
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, authentication }) => {
|
||||
const { query, scope, period, from, to, format } = body;
|
||||
const env = authentication.environment;
|
||||
|
||||
const queryResult = await executeQuery({
|
||||
name: "api-query",
|
||||
query,
|
||||
scope: scope as QueryScope,
|
||||
organizationId: env.organization.id,
|
||||
projectId: env.project.id,
|
||||
environmentId: env.id,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
history: {
|
||||
source: "API",
|
||||
},
|
||||
});
|
||||
|
||||
if (!queryResult.success) {
|
||||
const message =
|
||||
queryResult.error instanceof QueryError
|
||||
? queryResult.error.message
|
||||
: "An unexpected error occurred while executing the query.";
|
||||
|
||||
logger.error("Query API error", {
|
||||
error: queryResult.error,
|
||||
query,
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: message },
|
||||
{ status: queryResult.error instanceof QueryError ? 400 : 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const { result, periodClipped, maxQueryPeriod } = queryResult;
|
||||
|
||||
if (format === "csv") {
|
||||
const csv = rowsToCSV(result.rows, result.columns);
|
||||
|
||||
return json({
|
||||
format: "csv",
|
||||
results: csv,
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
format: "json",
|
||||
results: result.rows,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export { action, loader };
|
||||
@@ -128,7 +128,7 @@ export default function LoginPage() {
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "github" && <LastUsedBadge />}
|
||||
<Form
|
||||
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
action={`/auth/github${data.redirectTo ? `?redirectTo=${encodeURIComponent(data.redirectTo)}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
@@ -148,7 +148,7 @@ export default function LoginPage() {
|
||||
<div className="relative w-full">
|
||||
{data.lastAuthMethod === "google" && <LastUsedBadge />}
|
||||
<Form
|
||||
action={`/auth/google${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
action={`/auth/google${data.redirectTo ? `?redirectTo=${encodeURIComponent(data.redirectTo)}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { hasAccessToEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { executeQuery } from "~/services/queryService.server";
|
||||
import {
|
||||
QueryWidget,
|
||||
type QueryWidgetConfig,
|
||||
type QueryWidgetData,
|
||||
} from "~/components/metrics/QueryWidget";
|
||||
import { useElementVisibility } from "~/hooks/useElementVisibility";
|
||||
import { useInterval } from "~/hooks/useInterval";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
const Scope = z.union([z.literal("environment"), z.literal("organization"), z.literal("project")]);
|
||||
|
||||
// Response type for the action
|
||||
type MetricWidgetActionResponse =
|
||||
| { success: false; error: string }
|
||||
| {
|
||||
success: true;
|
||||
data: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
stats: { elapsed_ns: string } | null;
|
||||
hiddenColumns: string[] | null;
|
||||
reachedMaxRows: boolean;
|
||||
periodClipped: number | null;
|
||||
maxQueryPeriod: number | undefined;
|
||||
timeRange: { from: string; to: string };
|
||||
};
|
||||
};
|
||||
|
||||
const MetricWidgetQuery = z.object({
|
||||
query: z.string(),
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
scope: Scope,
|
||||
period: z.string().nullable(),
|
||||
from: z.string().nullable(),
|
||||
to: z.string().nullable(),
|
||||
taskIdentifiers: z.array(z.string()).optional(),
|
||||
queues: z.array(z.string()).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const data = await request.json();
|
||||
const submission = MetricWidgetQuery.safeParse(data);
|
||||
|
||||
if (!submission.success) {
|
||||
return json(
|
||||
{
|
||||
success: false as const,
|
||||
error: "Invalid input",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
query,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
scope,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
taskIdentifiers,
|
||||
queues,
|
||||
tags,
|
||||
} = submission.data;
|
||||
|
||||
// Check they should be able to access it
|
||||
const hasAccess = await hasAccessToEnvironment({
|
||||
environmentId,
|
||||
projectId,
|
||||
organizationId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return json(
|
||||
{
|
||||
success: false as const,
|
||||
error: "You don't have permission for this resource",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const queryResult = await executeQuery({
|
||||
name: "query-page",
|
||||
query,
|
||||
scope,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
taskIdentifiers,
|
||||
queues,
|
||||
// Set higher concurrency if many widgets are on screen at once
|
||||
customOrgConcurrencyLimit: env.METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT,
|
||||
});
|
||||
|
||||
if (!queryResult.success) {
|
||||
return json(
|
||||
{
|
||||
success: false as const,
|
||||
error: queryResult.error.message,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true as const,
|
||||
data: {
|
||||
rows: queryResult.result.rows,
|
||||
columns: queryResult.result.columns,
|
||||
stats: queryResult.result.stats,
|
||||
hiddenColumns: queryResult.result.hiddenColumns ?? null,
|
||||
reachedMaxRows: queryResult.result.reachedMaxRows,
|
||||
periodClipped: queryResult.periodClipped,
|
||||
maxQueryPeriod: queryResult.maxQueryPeriod,
|
||||
timeRange: {
|
||||
from: queryResult.timeRange.from.toISOString(),
|
||||
to: queryResult.timeRange.to.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type MetricWidgetProps = {
|
||||
/** Unique key for this widget - used to identify the fetcher */
|
||||
widgetKey: string;
|
||||
title: string;
|
||||
config: QueryWidgetConfig;
|
||||
refreshIntervalMs?: number;
|
||||
isResizing?: boolean;
|
||||
isDraggable?: boolean;
|
||||
/** Callback when edit button is clicked - receives current data */
|
||||
onEdit?: (data: QueryWidgetData) => void;
|
||||
/** Callback when rename is clicked - receives new title */
|
||||
onRename?: (newTitle: string) => void;
|
||||
/** Callback when delete is clicked */
|
||||
onDelete?: () => void;
|
||||
/** Callback when duplicate is clicked - receives current data */
|
||||
onDuplicate?: (data: QueryWidgetData) => void;
|
||||
} & z.infer<typeof MetricWidgetQuery>;
|
||||
|
||||
export function MetricWidget({
|
||||
widgetKey,
|
||||
title,
|
||||
config,
|
||||
refreshIntervalMs,
|
||||
isResizing,
|
||||
isDraggable,
|
||||
onEdit,
|
||||
onRename,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
...props
|
||||
}: MetricWidgetProps) {
|
||||
const [response, setResponse] = useState<MetricWidgetActionResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Track the latest props so the submit callback always uses fresh values
|
||||
// without needing to be recreated (which would cause useInterval to re-register listeners).
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
// Skip fetching if the widget is not visible on screen
|
||||
if (!isVisibleRef.current) return;
|
||||
|
||||
// Abort any in-flight request for this widget
|
||||
abortControllerRef.current?.abort();
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
setIsLoading(true);
|
||||
|
||||
fetch(`/resources/metric`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(propsRef.current),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
try {
|
||||
return (await res.json()) as MetricWidgetActionResponse;
|
||||
} catch {
|
||||
throw new Error(`Request failed (${res.status})`);
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setResponse(data);
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
if (!controller.signal.aborted) {
|
||||
// Only surface the error if there's no existing successful data to preserve
|
||||
setResponse((prev) =>
|
||||
prev?.success ? prev : { success: false, error: err.message || "Network error" }
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Track visibility so we only fetch for on-screen widgets.
|
||||
// When a widget scrolls into view and has no data yet, trigger a load.
|
||||
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
|
||||
onVisibilityChange: (visible) => {
|
||||
if (visible && !response) {
|
||||
submit();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reload periodically and on focus (onLoad: false — the useEffect below handles initial load)
|
||||
useInterval({ interval: refreshIntervalMs, callback: submit, onLoad: false });
|
||||
|
||||
// Reload on mount and when query, time period, or filters change
|
||||
useEffect(() => {
|
||||
submit();
|
||||
}, [
|
||||
submit,
|
||||
props.query,
|
||||
props.from,
|
||||
props.to,
|
||||
props.period,
|
||||
props.scope,
|
||||
JSON.stringify(props.taskIdentifiers),
|
||||
JSON.stringify(props.queues),
|
||||
]);
|
||||
|
||||
const data = response?.success
|
||||
? { rows: response.data.rows, columns: response.data.columns }
|
||||
: { rows: [], columns: [] };
|
||||
|
||||
const timeRange = response?.success ? response.data.timeRange : undefined;
|
||||
|
||||
return (
|
||||
<div ref={visibilityRef} className="h-full">
|
||||
<QueryWidget
|
||||
title={title}
|
||||
titleString={title}
|
||||
query={props.query}
|
||||
config={config}
|
||||
isLoading={isLoading}
|
||||
data={data}
|
||||
timeRange={timeRange}
|
||||
error={response?.success === false ? response.error : undefined}
|
||||
isResizing={isResizing}
|
||||
isDraggable={isDraggable}
|
||||
onEdit={onEdit}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
onDuplicate={onDuplicate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
import { type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
DashboardLayout,
|
||||
LayoutItem,
|
||||
} from "~/presenters/v3/MetricDashboardPresenter.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
// Schemas for each action type
|
||||
const AddWidgetSchema = z.object({
|
||||
widgetId: z.string().min(1, "Widget ID is required").nullish(),
|
||||
title: z.string().min(1, "Title is required"),
|
||||
query: z.string().default(""),
|
||||
config: z.string().transform((str, ctx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
const result = QueryWidgetConfig.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid widget config",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return result.data;
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid JSON",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const UpdateWidgetSchema = z.object({
|
||||
widgetId: z.string().min(1, "Widget ID is required"),
|
||||
title: z.string().min(1, "Title is required"),
|
||||
query: z.string().min(1, "Query is required"),
|
||||
config: z.string().transform((str, ctx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
const result = QueryWidgetConfig.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid widget config",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return result.data;
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid JSON",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const RenameWidgetSchema = z.object({
|
||||
widgetId: z.string().min(1, "Widget ID is required"),
|
||||
title: z.string().min(1, "Title is required"),
|
||||
});
|
||||
|
||||
const DeleteWidgetSchema = z.object({
|
||||
widgetId: z.string().min(1, "Widget ID is required"),
|
||||
});
|
||||
|
||||
const DuplicateWidgetSchema = z.object({
|
||||
widgetId: z.string().min(1, "Widget ID is required"),
|
||||
newId: z.string().min(1, "New widget ID is required").nullish(),
|
||||
});
|
||||
|
||||
const SaveLayoutSchema = z.object({
|
||||
layout: z.string().transform((str, ctx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
const result = z.array(LayoutItem).safeParse(parsed);
|
||||
if (!result.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid layout format",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return result.data;
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid JSON",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const ParamsSchema = EnvironmentParamSchema.extend({
|
||||
dashboardId: z.string(),
|
||||
});
|
||||
|
||||
// Check widget limit for add/duplicate actions (title widgets don't count)
|
||||
async function checkWidgetLimit(
|
||||
existingLayout: z.infer<typeof DashboardLayout>,
|
||||
organizationId: string
|
||||
) {
|
||||
const currentWidgetCount = Object.values(existingLayout.widgets).filter(
|
||||
(w) => w.display.type !== "title"
|
||||
).length;
|
||||
const plan = await getCurrentPlan(organizationId);
|
||||
const metricWidgetsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
|
||||
?.metricWidgetsPerDashboard;
|
||||
const widgetLimit =
|
||||
typeof metricWidgetsLimitValue === "number"
|
||||
? metricWidgetsLimitValue
|
||||
: (metricWidgetsLimitValue?.number ?? 16);
|
||||
|
||||
if (currentWidgetCount >= widgetLimit) {
|
||||
throw new Response("Widget limit reached", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic concurrency save: uses updateMany so we can include updatedAt
|
||||
// in the where clause. If another request modified the dashboard between our
|
||||
// read and this write, updatedAt won't match and count will be 0.
|
||||
async function saveDashboardLayout(
|
||||
dashboardId: string,
|
||||
expectedUpdatedAt: Date,
|
||||
updatedLayout: z.infer<typeof DashboardLayout>
|
||||
) {
|
||||
const result = await prisma.metricsDashboard.updateMany({
|
||||
where: { id: dashboardId, updatedAt: expectedUpdatedAt },
|
||||
data: {
|
||||
layout: JSON.stringify(updatedLayout),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
throw new Response(
|
||||
"Dashboard was modified by another request. Please refresh and try again.",
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam, dashboardId } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Load the dashboard
|
||||
const dashboard = await prisma.metricsDashboard.findFirst({
|
||||
where: {
|
||||
friendlyId: dashboardId,
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new Response("Dashboard not found", { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action");
|
||||
|
||||
// Parse existing layout (shared across all actions)
|
||||
let existingLayout: z.infer<typeof DashboardLayout>;
|
||||
try {
|
||||
const parsed = JSON.parse(dashboard.layout);
|
||||
const layoutResult = DashboardLayout.safeParse(parsed);
|
||||
if (!layoutResult.success) {
|
||||
// For add action, we can start with empty layout
|
||||
if (action === "add") {
|
||||
existingLayout = {
|
||||
version: "1",
|
||||
layout: [],
|
||||
widgets: {},
|
||||
};
|
||||
} else {
|
||||
throw new Response("Invalid dashboard layout", { status: 500 });
|
||||
}
|
||||
} else {
|
||||
existingLayout = layoutResult.data;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Response) throw e;
|
||||
if (action === "add") {
|
||||
existingLayout = {
|
||||
version: "1",
|
||||
layout: [],
|
||||
widgets: {},
|
||||
};
|
||||
} else {
|
||||
throw new Response("Failed to parse dashboard layout", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "add": {
|
||||
const rawData = {
|
||||
widgetId: formData.get("widgetId"),
|
||||
title: formData.get("title"),
|
||||
query: formData.get("query"),
|
||||
config: formData.get("config"),
|
||||
};
|
||||
|
||||
const result = AddWidgetSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
||||
}
|
||||
|
||||
const { title, query, config } = result.data;
|
||||
|
||||
// Validate that non-title widgets have a query
|
||||
if (config.type !== "title" && !query) {
|
||||
throw new Response("Query is required for chart widgets", { status: 400 });
|
||||
}
|
||||
|
||||
// Title widgets don't count against the limit
|
||||
if (config.type !== "title") {
|
||||
await checkWidgetLimit(existingLayout, project.organizationId);
|
||||
}
|
||||
|
||||
// Use client-provided widget ID if available, otherwise generate one
|
||||
// Using the client's ID ensures optimistic UI state stays in sync with the server
|
||||
const widgetId = result.data.widgetId || nanoid(8);
|
||||
|
||||
// Calculate position at the bottom
|
||||
let maxBottom = 0;
|
||||
for (const item of existingLayout.layout) {
|
||||
const itemBottom = item.y + item.h;
|
||||
if (itemBottom > maxBottom) {
|
||||
maxBottom = itemBottom;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new layout item (full width, height depends on widget type)
|
||||
const newLayoutItem = {
|
||||
i: widgetId,
|
||||
x: 0,
|
||||
y: maxBottom,
|
||||
w: 12,
|
||||
h: config.type === "title" ? 2 : 15,
|
||||
};
|
||||
|
||||
// Add new widget
|
||||
const newWidget = {
|
||||
title,
|
||||
query,
|
||||
display: config,
|
||||
};
|
||||
|
||||
// Update the layout
|
||||
const updatedLayout = {
|
||||
...existingLayout,
|
||||
layout: [...existingLayout.layout, newLayoutItem],
|
||||
widgets: {
|
||||
...existingLayout.widgets,
|
||||
[widgetId]: newWidget,
|
||||
},
|
||||
};
|
||||
|
||||
// Save to database (with optimistic concurrency check)
|
||||
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
||||
|
||||
return typedjson({ success: true, widgetId });
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const rawData = {
|
||||
widgetId: formData.get("widgetId"),
|
||||
title: formData.get("title"),
|
||||
query: formData.get("query"),
|
||||
config: formData.get("config"),
|
||||
};
|
||||
|
||||
const result = UpdateWidgetSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
||||
}
|
||||
|
||||
const { widgetId, title, query, config } = result.data;
|
||||
|
||||
// Check if widget exists
|
||||
if (!existingLayout.widgets[widgetId]) {
|
||||
throw new Response("Widget not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Update the widget
|
||||
const updatedWidget = {
|
||||
title,
|
||||
query,
|
||||
display: config,
|
||||
};
|
||||
|
||||
// Update the layout
|
||||
const updatedLayout = {
|
||||
...existingLayout,
|
||||
widgets: {
|
||||
...existingLayout.widgets,
|
||||
[widgetId]: updatedWidget,
|
||||
},
|
||||
};
|
||||
|
||||
// Save to database (with optimistic concurrency check)
|
||||
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
||||
|
||||
return typedjson({ success: true, updatedTitle: title });
|
||||
}
|
||||
|
||||
case "rename": {
|
||||
const rawData = {
|
||||
widgetId: formData.get("widgetId"),
|
||||
title: formData.get("title"),
|
||||
};
|
||||
|
||||
const result = RenameWidgetSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
||||
}
|
||||
|
||||
const { widgetId, title } = result.data;
|
||||
|
||||
// Check if widget exists
|
||||
if (!existingLayout.widgets[widgetId]) {
|
||||
throw new Response("Widget not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Update just the title
|
||||
const updatedLayout = {
|
||||
...existingLayout,
|
||||
widgets: {
|
||||
...existingLayout.widgets,
|
||||
[widgetId]: {
|
||||
...existingLayout.widgets[widgetId],
|
||||
title,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Save to database (with optimistic concurrency check)
|
||||
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
||||
|
||||
return typedjson({ success: true, renamedTitle: title });
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const rawData = {
|
||||
widgetId: formData.get("widgetId"),
|
||||
};
|
||||
|
||||
const result = DeleteWidgetSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
||||
}
|
||||
|
||||
const { widgetId } = result.data;
|
||||
|
||||
// Get widget title before deleting (for the success message)
|
||||
const widget = existingLayout.widgets[widgetId];
|
||||
const widgetTitle = widget?.title ?? "Widget";
|
||||
|
||||
// Remove widget from layout and widgets
|
||||
const updatedLayout = {
|
||||
...existingLayout,
|
||||
layout: existingLayout.layout.filter((item) => item.i !== widgetId),
|
||||
widgets: Object.fromEntries(
|
||||
Object.entries(existingLayout.widgets).filter(([key]) => key !== widgetId)
|
||||
),
|
||||
};
|
||||
|
||||
// Save to database (with optimistic concurrency check)
|
||||
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
||||
|
||||
return typedjson({ success: true, deletedTitle: widgetTitle });
|
||||
}
|
||||
|
||||
case "duplicate": {
|
||||
await checkWidgetLimit(existingLayout, project.organizationId);
|
||||
|
||||
const rawData = {
|
||||
widgetId: formData.get("widgetId"),
|
||||
newId: formData.get("newId"),
|
||||
};
|
||||
|
||||
const result = DuplicateWidgetSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
||||
}
|
||||
|
||||
const { widgetId } = result.data;
|
||||
|
||||
// Find the original widget
|
||||
const originalWidget = existingLayout.widgets[widgetId];
|
||||
if (!originalWidget) {
|
||||
throw new Response("Widget not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Find the original layout item
|
||||
const originalLayoutItem = existingLayout.layout.find((item) => item.i === widgetId);
|
||||
if (!originalLayoutItem) {
|
||||
throw new Response("Widget layout not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Use client-provided ID if available, otherwise generate one
|
||||
// Using the client's ID ensures optimistic UI state stays in sync with the server
|
||||
const newWidgetId = result.data.newId || nanoid(8);
|
||||
|
||||
// Calculate position at the bottom
|
||||
let maxBottom = 0;
|
||||
for (const item of existingLayout.layout) {
|
||||
const itemBottom = item.y + item.h;
|
||||
if (itemBottom > maxBottom) {
|
||||
maxBottom = itemBottom;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new layout item with same dimensions but at the bottom
|
||||
const newLayoutItem = {
|
||||
i: newWidgetId,
|
||||
x: 0,
|
||||
y: maxBottom,
|
||||
w: originalLayoutItem.w,
|
||||
h: originalLayoutItem.h,
|
||||
};
|
||||
|
||||
// Create new widget with "(Copy)" suffix
|
||||
const newWidget = {
|
||||
...originalWidget,
|
||||
title: `${originalWidget.title} (Copy)`,
|
||||
};
|
||||
|
||||
// Update the layout
|
||||
const updatedLayout = {
|
||||
...existingLayout,
|
||||
layout: [...existingLayout.layout, newLayoutItem],
|
||||
widgets: {
|
||||
...existingLayout.widgets,
|
||||
[newWidgetId]: newWidget,
|
||||
},
|
||||
};
|
||||
|
||||
// Save to database (with optimistic concurrency check)
|
||||
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
||||
|
||||
return typedjson({ success: true, duplicatedTitle: originalWidget.title });
|
||||
}
|
||||
|
||||
case "layout": {
|
||||
const result = SaveLayoutSchema.safeParse({
|
||||
layout: formData.get("layout"),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Response("Invalid form data: " + result.error.message, { status: 400 });
|
||||
}
|
||||
|
||||
// Update layout positions while preserving widgets
|
||||
const updatedLayout = {
|
||||
...existingLayout,
|
||||
layout: result.data.layout,
|
||||
};
|
||||
|
||||
// Save to database (with optimistic concurrency check)
|
||||
await saveDashboardLayout(dashboard.id, dashboard.updatedAt, updatedLayout);
|
||||
|
||||
return typedjson({ success: true });
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Response("Invalid action", { status: 400 });
|
||||
}
|
||||
}
|
||||
};
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
|
||||
const CreateDashboardSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
description: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Check dashboard limit
|
||||
const [plan, existingCount] = await Promise.all([
|
||||
getCurrentPlan(project.organizationId),
|
||||
prisma.metricsDashboard.count({
|
||||
where: { organizationId: project.organizationId },
|
||||
}),
|
||||
]);
|
||||
|
||||
const metricDashboardsLimitValue = (plan?.v3Subscription?.plan?.limits as any)
|
||||
?.metricDashboards;
|
||||
const dashboardLimit =
|
||||
typeof metricDashboardsLimitValue === "number"
|
||||
? metricDashboardsLimitValue
|
||||
: (metricDashboardsLimitValue?.number ?? 3);
|
||||
|
||||
if (existingCount >= dashboardLimit) {
|
||||
throw new Response("Dashboard limit reached", { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rawData = {
|
||||
title: formData.get("title"),
|
||||
description: formData.get("description") ?? "",
|
||||
};
|
||||
|
||||
const result = CreateDashboardSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
throw new Response("Invalid form data", { status: 400 });
|
||||
}
|
||||
|
||||
const { title, description } = result.data;
|
||||
|
||||
// Create empty default layout
|
||||
const defaultLayout = JSON.stringify({
|
||||
version: "1",
|
||||
layout: [],
|
||||
widgets: {},
|
||||
});
|
||||
|
||||
const dashboard = await prisma.metricsDashboard.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("dashboard"),
|
||||
title,
|
||||
description,
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
ownerId: userId,
|
||||
layout: defaultLayout,
|
||||
},
|
||||
});
|
||||
|
||||
// Redirect to the new dashboard
|
||||
return redirect(
|
||||
v3CustomDashboardPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: dashboard.friendlyId }
|
||||
)
|
||||
);
|
||||
};
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
|
||||
// Valid TaskRunStatus values
|
||||
const VALID_TASK_RUN_STATUSES = [
|
||||
"PENDING",
|
||||
"QUEUED",
|
||||
"EXECUTING",
|
||||
"WAITING_FOR_EXECUTION",
|
||||
"WAITING",
|
||||
"COMPLETED_SUCCESSFULLY",
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
"SYSTEM_FAILURE",
|
||||
"FAILURE",
|
||||
"CANCELED",
|
||||
] as const;
|
||||
|
||||
// Schema for validating run context data
|
||||
export const RunContextSchema = z.object({
|
||||
id: z.string(),
|
||||
friendlyId: z.string(),
|
||||
taskIdentifier: z.string(),
|
||||
status: z.enum(VALID_TASK_RUN_STATUSES),
|
||||
createdAt: z.string().datetime(),
|
||||
startedAt: z.string().datetime().optional(),
|
||||
completedAt: z.string().datetime().optional(),
|
||||
isTest: z.boolean(),
|
||||
tags: z.array(z.string()),
|
||||
queue: z.string(),
|
||||
concurrencyKey: z.string().nullable(),
|
||||
usageDurationMs: z.number(),
|
||||
costInCents: z.number(),
|
||||
baseCostInCents: z.number(),
|
||||
machinePreset: MachinePresetName.nullable(),
|
||||
version: z.string().optional(),
|
||||
rootRun: z
|
||||
.object({
|
||||
friendlyId: z.string(),
|
||||
taskIdentifier: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
parentRun: z
|
||||
.object({
|
||||
friendlyId: z.string(),
|
||||
taskIdentifier: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
batch: z
|
||||
.object({
|
||||
friendlyId: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
schedule: z
|
||||
.object({
|
||||
friendlyId: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type RunContext = z.infer<typeof RunContextSchema>;
|
||||
|
||||
// Fetch run context for a log entry
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, logId } = {
|
||||
...EnvironmentParamSchema.parse(params),
|
||||
logId: params.logId,
|
||||
};
|
||||
|
||||
if (!logId) {
|
||||
throw new Response("Log ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Parse the logId to extract runId
|
||||
// Log ID format: traceId::spanId::runId::startTime (base64 encoded or plain)
|
||||
const url = new URL(request.url);
|
||||
const runId = url.searchParams.get("runId");
|
||||
|
||||
if (!runId) {
|
||||
throw new Response("Run ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch run details from Postgres
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
startedAt: true,
|
||||
completedAt: true,
|
||||
isTest: true,
|
||||
runTags: true,
|
||||
queue: true,
|
||||
concurrencyKey: true,
|
||||
usageDurationMs: true,
|
||||
costInCents: true,
|
||||
baseCostInCents: true,
|
||||
machinePreset: true,
|
||||
scheduleId: true,
|
||||
lockedToVersion: {
|
||||
select: {
|
||||
version: true,
|
||||
},
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
},
|
||||
},
|
||||
parentTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
},
|
||||
},
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
friendlyId: runId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return json({ run: null });
|
||||
}
|
||||
|
||||
// Fetch schedule if scheduleId exists
|
||||
let schedule: { friendlyId: string } | null = null;
|
||||
if (run.scheduleId) {
|
||||
const scheduleData = await $replica.taskSchedule.findFirst({
|
||||
select: { friendlyId: true },
|
||||
where: { id: run.scheduleId },
|
||||
});
|
||||
schedule = scheduleData;
|
||||
}
|
||||
|
||||
const runData = {
|
||||
id: run.id,
|
||||
friendlyId: run.friendlyId,
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
status: run.status,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
startedAt: run.startedAt?.toISOString(),
|
||||
completedAt: run.completedAt?.toISOString(),
|
||||
isTest: run.isTest,
|
||||
tags: run.runTags,
|
||||
queue: run.queue,
|
||||
concurrencyKey: run.concurrencyKey,
|
||||
usageDurationMs: run.usageDurationMs,
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
machinePreset: run.machinePreset,
|
||||
version: run.lockedToVersion?.version,
|
||||
rootRun: run.rootTaskRun
|
||||
? {
|
||||
friendlyId: run.rootTaskRun.friendlyId,
|
||||
taskIdentifier: run.rootTaskRun.taskIdentifier,
|
||||
}
|
||||
: null,
|
||||
parentRun: run.parentTaskRun
|
||||
? {
|
||||
friendlyId: run.parentTaskRun.friendlyId,
|
||||
taskIdentifier: run.parentTaskRun.taskIdentifier,
|
||||
}
|
||||
: null,
|
||||
batch: run.batch ? { friendlyId: run.batch.friendlyId } : null,
|
||||
schedule: schedule,
|
||||
};
|
||||
|
||||
// Validate the run data
|
||||
const validatedRun = RunContextSchema.parse(runData);
|
||||
|
||||
return json({
|
||||
run: validatedRun,
|
||||
});
|
||||
};
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/node";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
|
||||
// Convert ClickHouse kind to display level
|
||||
function kindToLevel(
|
||||
kind: string
|
||||
): "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG" {
|
||||
switch (kind) {
|
||||
case "DEBUG_EVENT":
|
||||
case "LOG_DEBUG":
|
||||
return "DEBUG";
|
||||
case "LOG_INFO":
|
||||
return "INFO";
|
||||
case "LOG_WARN":
|
||||
return "WARN";
|
||||
case "LOG_ERROR":
|
||||
return "ERROR";
|
||||
case "LOG_LOG":
|
||||
return "LOG";
|
||||
case "SPAN":
|
||||
case "ANCESTOR_OVERRIDE":
|
||||
case "SPAN_EVENT":
|
||||
default:
|
||||
return "TRACE";
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch related spans for a log entry from the same trace
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, logId } = {
|
||||
...EnvironmentParamSchema.parse(params),
|
||||
logId: params.logId,
|
||||
};
|
||||
|
||||
if (!logId) {
|
||||
throw new Response("Log ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Get trace ID and run ID from query params
|
||||
const url = new URL(request.url);
|
||||
const traceId = url.searchParams.get("traceId");
|
||||
const runId = url.searchParams.get("runId");
|
||||
const currentSpanId = url.searchParams.get("spanId");
|
||||
|
||||
if (!traceId || !runId) {
|
||||
throw new Response("Trace ID and Run ID are required", { status: 400 });
|
||||
}
|
||||
|
||||
// Query ClickHouse for related spans in the same trace
|
||||
const queryBuilder = clickhouseClient.taskEventsV2.logsListQueryBuilder();
|
||||
|
||||
queryBuilder.where("environment_id = {environmentId: String}", {
|
||||
environmentId: environment.id,
|
||||
});
|
||||
queryBuilder.where("trace_id = {traceId: String}", { traceId });
|
||||
queryBuilder.where("run_id = {runId: String}", { runId });
|
||||
|
||||
// Order by start time to show spans in chronological order
|
||||
queryBuilder.orderBy("start_time ASC");
|
||||
queryBuilder.limit(50);
|
||||
|
||||
const [queryError, records] = await queryBuilder.execute();
|
||||
|
||||
if (queryError) {
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
const results = records || [];
|
||||
|
||||
const spans = results.map((row) => ({
|
||||
id: `${row.trace_id}::${row.span_id}::${row.run_id}::${row.start_time}`,
|
||||
spanId: row.span_id,
|
||||
parentSpanId: row.parent_span_id || null,
|
||||
message: row.message.substring(0, 200), // Truncate for list view
|
||||
kind: row.kind,
|
||||
level: kindToLevel(row.kind),
|
||||
status: row.status,
|
||||
startTime: new Date(Number(row.start_time) / 1_000_000).toISOString(),
|
||||
duration: Number(row.duration),
|
||||
isCurrent: row.span_id === currentSpanId,
|
||||
}));
|
||||
|
||||
return json({ spans });
|
||||
};
|
||||
+17
-3
@@ -1,13 +1,14 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { LogDetailPresenter } from "~/presenters/v3/LogDetailPresenter.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
|
||||
const LogIdParamsSchema = z.object({
|
||||
organizationSlug: z.string(),
|
||||
@@ -42,7 +43,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const [traceId, spanId, , startTime] = parts;
|
||||
|
||||
const presenter = new LogDetailPresenter($replica, clickhouseClient);
|
||||
const presenter = new LogDetailPresenter($replica, logsClickhouseClient);
|
||||
|
||||
let result;
|
||||
try {
|
||||
@@ -65,5 +66,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Response("Log not found", { status: 404 });
|
||||
}
|
||||
|
||||
return typedjson(result);
|
||||
// Look up the run status from Postgres
|
||||
let runStatus: TaskRunStatus | undefined;
|
||||
if (result.runId) {
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
select: { status: true },
|
||||
where: {
|
||||
friendlyId: result.runId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
});
|
||||
runStatus = run?.status;
|
||||
}
|
||||
|
||||
return typedjson({ ...result, runStatus });
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user