@@ -1060,7 +1078,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
@@ -1147,7 +1165,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
+ 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));
diff --git a/apps/webapp/app/components/code/tsql/tsqlCompletion.ts b/apps/webapp/app/components/code/tsql/tsqlCompletion.ts
index 047496dde..b53c551a4 100644
--- a/apps/webapp/app/components/code/tsql/tsqlCompletion.ts
+++ b/apps/webapp/app/components/code/tsql/tsqlCompletion.ts
@@ -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;
}
diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx
index 0793c52ca..e0b58ed71 100644
--- a/apps/webapp/app/components/layout/AppLayout.tsx
+++ b/apps/webapp/app/components/layout/AppLayout.tsx
@@ -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 {children} ;
+ return {children} ;
}
export function PageBody({
diff --git a/apps/webapp/app/components/logs/LogsTaskFilter.tsx b/apps/webapp/app/components/logs/LogsTaskFilter.tsx
index 3f95e0741..fa64eff7b 100644
--- a/apps/webapp/app/components/logs/LogsTaskFilter.tsx
+++ b/apps/webapp/app/components/logs/LogsTaskFilter.tsx
@@ -43,7 +43,7 @@ export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
shortcut={shortcut}
tooltipTitle="Filter by task"
>
- Tasks
+ Tasks
}
searchValue={search}
@@ -114,7 +114,7 @@ function TasksDropdown({
{trigger}
{
if (onClose) {
onClose();
diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx
new file mode 100644
index 000000000..7e49259f8
--- /dev/null
+++ b/apps/webapp/app/components/metrics/QueryWidget.tsx
@@ -0,0 +1,496 @@
+import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
+import { ClipboardIcon } from "@heroicons/react/24/outline";
+import { ChartBarIcon } from "@heroicons/react/24/solid";
+import { type OutputColumnMetadata } from "@internal/tsql";
+import { DialogClose } from "@radix-ui/react-dialog";
+import { IconBraces, IconChartHistogram, IconFileTypeCsv } from "@tabler/icons-react";
+import { assertNever } from "assert-never";
+import { Maximize2 } from "lucide-react";
+import { useCallback, useState, type ReactNode } from "react";
+import { z } from "zod";
+import { Card } from "~/components/primitives/charts/Card";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import { cn } from "~/utils/cn";
+import { rowsToCSV, rowsToJSON } from "~/utils/dataExport";
+import { QueryResultsChart } from "../code/QueryResultsChart";
+import { TSQLResultsTable } from "../code/TSQLResultsTable";
+import { Button } from "../primitives/Buttons";
+import { Callout } from "../primitives/Callout";
+import { BigNumberCard } from "../primitives/charts/BigNumberCard";
+import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
+import { Input } from "../primitives/Input";
+import { InputGroup } from "../primitives/InputGroup";
+import { Label } from "../primitives/Label";
+import { LoadingBarDivider } from "../primitives/LoadingBarDivider";
+import {
+ Popover,
+ PopoverContent,
+ PopoverMenuItem,
+ PopoverVerticalEllipseTrigger,
+} from "../primitives/Popover";
+
+const ChartType = z.union([z.literal("bar"), z.literal("line")]);
+export type ChartType = z.infer;
+
+const SortDirection = z.union([z.literal("asc"), z.literal("desc")]);
+export type SortDirection = z.infer;
+
+const AggregationType = z.union([
+ z.literal("sum"),
+ z.literal("avg"),
+ z.literal("count"),
+ z.literal("min"),
+ z.literal("max"),
+]);
+export type AggregationType = z.infer;
+
+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;
+
+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;
+
+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;
+
+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;
+
+/** Result data containing rows and column metadata */
+export type QueryWidgetData = {
+ rows: Record[];
+ columns: OutputColumnMetadata[];
+};
+
+/** Widget configuration with optional result data (used for edit callbacks) */
+export type WidgetData = {
+ title: string;
+ query: string;
+ display: QueryWidgetConfig;
+ /** The current result data from the widget */
+ resultData?: QueryWidgetData;
+};
+
+export type QueryWidgetProps = {
+ title: ReactNode;
+ /** String title for rename dialog (optional - if not provided, rename won't be available) */
+ titleString?: string;
+ /** The TSQL query string (used for "Copy query" in the menu) */
+ query?: string;
+ isLoading?: boolean;
+ error?: string;
+ data: QueryWidgetData;
+ config: QueryWidgetConfig;
+ /** The effective time range for the query (used to show full x-axis on time-based charts) */
+ timeRange?: { from: string; to: string };
+ accessory?: ReactNode;
+ isResizing?: boolean;
+ isDraggable?: boolean;
+ /** Callback when edit is clicked. Receives the current data. */
+ onEdit?: (data: QueryWidgetData) => void;
+ /** Callback when rename is clicked. Receives the new title. */
+ onRename?: (newTitle: string) => void;
+ /** Callback when delete is clicked. */
+ onDelete?: () => void;
+ /** Callback when duplicate is clicked. Receives the current data. */
+ onDuplicate?: (data: QueryWidgetData) => void;
+};
+
+export function QueryWidget({
+ title,
+ titleString,
+ query,
+ accessory,
+ isLoading,
+ error,
+ isResizing,
+ isDraggable,
+ onEdit,
+ onRename,
+ onDelete,
+ onDuplicate,
+ ...props
+}: QueryWidgetProps) {
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [renameValue, setRenameValue] = useState(titleString ?? "");
+
+ const hasEditActions = onEdit || onRename || onDelete || onDuplicate;
+ const hasData = props.data.rows.length > 0;
+
+ const copyToClipboard = useCallback((text: string) => {
+ navigator.clipboard.writeText(text);
+ }, []);
+
+ const copyQuery = useCallback(() => {
+ if (query) {
+ copyToClipboard(query);
+ }
+ }, [query, copyToClipboard]);
+
+ const copyJSON = useCallback(() => {
+ copyToClipboard(rowsToJSON(props.data.rows));
+ }, [props.data.rows, copyToClipboard]);
+
+ const copyCSV = useCallback(() => {
+ copyToClipboard(rowsToCSV(props.data.rows, props.data.columns));
+ }, [props.data, copyToClipboard]);
+
+ return (
+
+
+
+ {title}
+
+
+
+
+
+
+ {isResizing ? (
+
+
+ {" "}
+ Resizing...
+
+
+ ) : error ? (
+
+ {error}
+
+ ) : (
+
+ )}
+
+
+
+ {/* Rename Dialog */}
+ {onRename && (
+
+ )}
+
+ );
+}
+
+type QueryWidgetBodyProps = {
+ title: ReactNode;
+ data: QueryWidgetData;
+ config: QueryWidgetConfig;
+ timeRange?: { from: string; to: string };
+ isFullscreen: boolean;
+ setIsFullscreen: (open: boolean) => void;
+ isLoading: boolean;
+};
+
+function QueryWidgetBody({
+ title,
+ data,
+ config,
+ timeRange,
+ isFullscreen,
+ setIsFullscreen,
+ isLoading,
+}: QueryWidgetBodyProps) {
+ const type = config.type;
+
+ // Only show the loading state if we have no data yet (initial load).
+ // During a reload with existing data, keep showing the current data
+ // while the loading bar in the header indicates a refresh is in progress.
+ const hasData = data.rows.length > 0;
+ const showLoading = isLoading && !hasData;
+
+ switch (type) {
+ case "table": {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ case "chart": {
+ return (
+ <>
+ setIsFullscreen(true)}
+ isLoading={showLoading}
+ />
+
+ >
+ );
+ }
+ case "bignumber": {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ case "title": {
+ // Title widgets are rendered by TitleWidget, not QueryWidget
+ return null;
+ }
+ default: {
+ assertNever(type);
+ }
+ }
+}
diff --git a/apps/webapp/app/components/metrics/QueuesFilter.tsx b/apps/webapp/app/components/metrics/QueuesFilter.tsx
new file mode 100644
index 000000000..87d7a6125
--- /dev/null
+++ b/apps/webapp/app/components/metrics/QueuesFilter.tsx
@@ -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 (
+
+ {(search, setSearch) => (
+ }
+ variant="secondary/small"
+ shortcut={shortcut}
+ tooltipTitle="Filter by queue"
+ >
+ Queues
+
+ }
+ searchValue={search}
+ clearSearchValue={() => setSearch("")}
+ />
+ )}
+
+ );
+ }
+
+ return (
+
+ {(search, setSearch) => (
+ }>
+ }
+ value={appliedSummary(selectedQueues.map((v) => v.replace("task/", "")))}
+ onRemove={() => del(["queues"])}
+ variant="secondary/small"
+ />
+
+ }
+ searchValue={search}
+ clearSearchValue={() => setSearch("")}
+ />
+ )}
+
+ );
+}
+
+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();
+
+ 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();
+
+ // 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 (
+
+ {trigger}
+ {
+ if (onClose) {
+ onClose();
+ return false;
+ }
+
+ return true;
+ }}
+ >
+ (
+
+
+ {fetcher.state === "loading" && }
+
+ )}
+ />
+
+ {filtered.length > 0
+ ? filtered.map((queue) => (
+
+ ) : (
+
+ )
+ }
+ >
+ {queue.name}
+
+ ))
+ : null}
+ {filtered.length === 0 && fetcher.state !== "loading" && (
+ No queues found
+ )}
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx b/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx
new file mode 100644
index 000000000..1e2c1232e
--- /dev/null
+++ b/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx
@@ -0,0 +1,177 @@
+import { DialogClose } from "@radix-ui/react-dialog";
+import { useFetcher, useNavigate } from "@remix-run/react";
+import { IconChartHistogram } from "@tabler/icons-react";
+import { useEffect, useState } from "react";
+import { useEnvironment } from "~/hooks/useEnvironment";
+import {
+ useCustomDashboards,
+ useOrganization,
+ useWidgetLimitPerDashboard,
+} from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { cn } from "~/utils/cn";
+import { v3CustomDashboardPath } from "~/utils/pathBuilder";
+import { Button } from "../primitives/Buttons";
+import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog";
+import { FormButtons } from "../primitives/FormButtons";
+import { Paragraph } from "../primitives/Paragraph";
+import type { QueryWidgetConfig } from "./QueryWidget";
+
+export type SaveToDashboardDialogProps = {
+ title: string;
+ query: string;
+ config: QueryWidgetConfig;
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function SaveToDashboardDialog({
+ title,
+ query,
+ config,
+ isOpen,
+ onOpenChange,
+}: SaveToDashboardDialogProps) {
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+ const customDashboards = useCustomDashboards();
+ const widgetLimit = useWidgetLimitPerDashboard();
+ const fetcher = useFetcher<{ success: boolean }>();
+ const navigate = useNavigate();
+
+ // Find the first dashboard that isn't at the widget limit
+ const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit);
+
+ const [selectedDashboardId, setSelectedDashboardId] = useState(
+ 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 (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/metrics/ScopeFilter.tsx b/apps/webapp/app/components/metrics/ScopeFilter.tsx
new file mode 100644
index 000000000..1bf6b6856
--- /dev/null
+++ b/apps/webapp/app/components/metrics/ScopeFilter.tsx
@@ -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 (
+
+ }>
+ }
+ value={}
+ removable={false}
+ variant="secondary/small"
+ />
+
+
+ {scopeOptions.map((option) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+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 ;
+ default:
+ return scope;
+ }
+}
diff --git a/apps/webapp/app/components/metrics/TitleWidget.tsx b/apps/webapp/app/components/metrics/TitleWidget.tsx
new file mode 100644
index 000000000..0a5a7590e
--- /dev/null
+++ b/apps/webapp/app/components/metrics/TitleWidget.tsx
@@ -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 (
+
+
+
+ {title}
+
+ {hasMenu && (
+
+
+
+
+
+ {onRename && (
+ {
+ setRenameValue(title);
+ setIsRenameDialogOpen(true);
+ setIsMenuOpen(false);
+ }}
+ />
+ )}
+ {onDelete && (
+ {
+ onDelete();
+ setIsMenuOpen(false);
+ }}
+ />
+ )}
+
+
+
+
+ )}
+
+
+ {/* Rename Dialog */}
+ {onRename && (
+
+ )}
+
+ );
+}
diff --git a/apps/webapp/app/components/navigation/DashboardDialogs.tsx b/apps/webapp/app/components/navigation/DashboardDialogs.tsx
new file mode 100644
index 000000000..a14681361
--- /dev/null
+++ b/apps/webapp/app/components/navigation/DashboardDialogs.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+ Upgrade to unlock dashboards
+
+
+
+ Custom metric dashboards are available on paid plans. Upgrade to create dashboards and
+ track your task metrics.
+
+
+
+
+
+
+
+ Upgrade plan
+
+
+
+ );
+ }
+
+ const percentage = Math.min(limits.used / limits.limit, 1);
+ const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
+
+ return (
+
+ Dashboard limit reached
+
+
+
+
+ {limits.limit}
+
+
+
+ {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{" "}
+ billing page for pricing
+ details.
+ >
+ )}
+
+
+
+
+
+
+ {canUpgrade ? (
+
+ Upgrade plan
+
+ ) : (
+ Request more…}
+ defaultValue="help"
+ />
+ )}
+
+
+ );
+}
+
+function CreateDashboardDialog({
+ formAction,
+ limits,
+}: {
+ formAction: string;
+ limits: { used: number; limit: number };
+}) {
+ const navigation = useNavigation();
+ const [title, setTitle] = useState("");
+
+ const isLoading = navigation.formAction === formAction;
+
+ return (
+
+ Create dashboard
+
+
+ );
+}
diff --git a/apps/webapp/app/components/navigation/DashboardList.tsx b/apps/webapp/app/components/navigation/DashboardList.tsx
new file mode 100644
index 000000000..132dc0268
--- /dev/null
+++ b/apps/webapp/app/components/navigation/DashboardList.tsx
@@ -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 & {
+ 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 (
+
+ {canReorder ? (
+
+ {orderedDashboards.map((dashboard, index) => {
+ const isLast = getIsLast(dashboard.friendlyId, index);
+ return (
+
+
+
+
+ }
+ />
+
+ );
+ })}
+
+ ) : (
+ orderedDashboards.map((dashboard, index) => {
+ const isLast = index === orderedDashboards.length - 1;
+ return (
+
+ );
+ })
+ )}
+
+ );
+}
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 95282f15f..241bb8aea 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -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 & {
+/** Get the collapsed state for a specific side menu section from user preferences */
+function getSectionCollapsed(
+ sideMenu: { collapsedSections?: Record } | 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(null);
const currentPlan = useCurrentPlan();
@@ -149,7 +166,11 @@ export function SideMenu({
const featureFlags = useFeatureFlags();
const persistSideMenuPreferences = useCallback(
- (data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
+ (data: {
+ isCollapsed?: boolean;
+ sectionId?: SideMenuSectionId;
+ sectionCollapsed?: boolean;
+ }) => {
if (user.isImpersonating) return;
// Merge with any pending changes
@@ -170,8 +191,9 @@ export function SideMenu({
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
- if (pending.manageSectionCollapsed !== undefined) {
- formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
+ if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
+ formData.append("sectionId", pending.sectionId);
+ formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
@@ -191,13 +213,18 @@ export function SideMenu({
}
if (user.isImpersonating) return;
const pending = pendingPreferencesRef.current;
- if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
+ const hasPendingChanges =
+ pending.isCollapsed !== undefined ||
+ (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined);
+
+ if (hasPendingChanges) {
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
- if (pending.manageSectionCollapsed !== undefined) {
- formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
+ if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
+ formData.append("sectionId", pending.sectionId);
+ formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
@@ -214,9 +241,10 @@ export function SideMenu({
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
};
- const handleManageSectionToggle = useCallback(
- (collapsed: boolean) => {
- persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
+ /** Generic handler for any collapsible section - just pass the section ID */
+ const handleSectionToggle = useCallback(
+ (sectionId: SideMenuSectionId) => (collapsed: boolean) => {
+ persistSideMenuPreferences({ sectionId, sectionCollapsed: collapsed });
},
[persistSideMenuPreferences]
);
@@ -255,294 +283,340 @@ export function SideMenu({
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
)}
>
-
-
+
+ {isAdmin && !user.isImpersonating ? (
+
+
+
+
+
+
+
+ Admin dashboard
+
+
+
+
+ ) : isAdmin && user.isImpersonating ? (
+
+
+
+ ) : null}
- {isAdmin && !user.isImpersonating ? (
-
-
-
-
-
-
-
- Admin dashboard
-
-
-
-
- ) : isAdmin && user.isImpersonating ? (
-
-
-
- ) : null}
-
-
-
-
-
-
-
+
+
+
- {environment.type === "DEVELOPMENT" && project.engine === "V2" && (
-
-
-
- )}
+
+
+ {environment.type === "DEVELOPMENT" && project.engine === "V2" && (
+
+
+
+ )}
+
-
-
-
-
-
-
-
-
-
- {(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
+
}
+ name="Tasks"
+ icon={TaskIconSmall}
+ activeIconColor="text-tasks"
+ inactiveIconColor="text-tasks"
+ to={v3EnvironmentPath(organization, project, environment)}
+ data-action="tasks"
isCollapsed={isCollapsed}
/>
- )}
-
+
+
+
+
+
+
+ {(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
+ }
+ isCollapsed={isCollapsed}
+ />
+ )}
+
+
+
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
- }
- isCollapsed={isCollapsed}
- />
+
+
+
+ }
+ />
+
+
)}
-
-
-
-
-
-
-
- {isManagedCloud && (
+
- )}
-
-
-
-
-
-
-
-
-
-
- {isFreeUser && (
-
-
-
- )}
-
-
+
+
+
+ {isManagedCloud && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {isFreeUser && (
+
+
+
+ )}
+
+
);
@@ -890,7 +964,12 @@ function CollapsibleHeight({
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
return (
-
+
@@ -909,7 +988,7 @@ function AnimatedChevron({
// When hovering and expanded: left chevron (pointing left to collapse)
// When hovering and collapsed: right chevron (pointing right to expand)
// When not hovering: straight vertical line
-
+
const getRotation = () => {
if (!isHovering) return { top: 0, bottom: 0 };
if (isCollapsed) {
@@ -922,7 +1001,7 @@ function AnimatedChevron({
};
const { top, bottom } = getRotation();
-
+
// Calculate horizontal offset to keep chevron centered when rotated
// Left chevron: translate left (-1.5px)
// Right chevron: translate right (+1.5px)
@@ -938,7 +1017,7 @@ function AnimatedChevron({
viewBox="0 0 4 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
- className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 group-hover:text-text-bright transition-colors"
+ className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 transition-colors group-hover:text-text-bright"
initial={false}
animate={{
x: getTranslateX(),
@@ -981,22 +1060,18 @@ function AnimatedChevron({
);
}
-function CollapseToggle({
- isCollapsed,
- onToggle,
-}: {
- isCollapsed: boolean;
- onToggle: () => void;
-}) {
+function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) {
const [isHovering, setIsHovering] = useState(false);
return (
{/* Vertical line to mask the side menu border */}
-
+
diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx
index a89765ad4..844782ed6 100644
--- a/apps/webapp/app/components/navigation/SideMenuItem.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx
@@ -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["target"];
isCollapsed?: boolean;
+ action?: ReactNode;
}) {
const pathName = usePathName();
const isActive = pathName === to;
- return (
-
-
+ const link = (
+
+
+
+ {name}
+ {badge && !isCollapsed && (
- {name}
- {badge && !isCollapsed && (
-
- {badge}
-
- )}
- {trailingIcon && !isCollapsed && (
-
- )}
+ {badge}
-
- }
+ )}
+ {trailingIcon && !isCollapsed && (
+
+ )}
+
+
+ );
+
+ if (action) {
+ return (
+
+
+ {!isCollapsed && (
+
+ {action}
+
+ )}
+
+ );
+ }
+
+ return (
+
{/* Header - fades out when sidebar is collapsed */}
- {title}
-
-
-
+
+ {title}
+
+
+
+
+ {headerAction && {headerAction} }
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
+
+
+
+ );
+}
+
+export function TreeConnectorEnd({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts
new file mode 100644
index 000000000..64afdf58e
--- /dev/null
+++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts
@@ -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;
diff --git a/apps/webapp/app/components/navigation/useReorderableList.ts b/apps/webapp/app/components/navigation/useReorderableList.ts
new file mode 100644
index 000000000..b73a054ae
--- /dev/null
+++ b/apps/webapp/app/components/navigation/useReorderableList.ts
@@ -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({
+ 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(
+ () => 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(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,
+ gridWidth,
+ gridMounted,
+ canReorder,
+ handleDrag,
+ handleDragStop,
+ getIsLast,
+ };
+}
diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx
index f540c4f35..a7a27f410 100644
--- a/apps/webapp/app/components/primitives/AppliedFilter.tsx
+++ b/apps/webapp/app/components/primitives/AppliedFilter.tsx
@@ -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
)}
>
-
+
{icon}
- {label &&
- {label}:
- }
+ {label && (
+
+ {label}:
+
+ )}
-
diff --git a/apps/webapp/app/components/primitives/ClientTabs.tsx b/apps/webapp/app/components/primitives/ClientTabs.tsx
index bc3943e82..737f37bcd 100644
--- a/apps/webapp/app/components/primitives/ClientTabs.tsx
+++ b/apps/webapp/app/components/primitives/ClientTabs.tsx
@@ -190,7 +190,8 @@ const ClientTabsContent = React.forwardRef<
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
- className
+ className,
+ "data-[state=inactive]:hidden"
)}
{...props}
/>
diff --git a/apps/webapp/app/components/primitives/FormButtons.tsx b/apps/webapp/app/components/primitives/FormButtons.tsx
index f3e4d93e8..ab4e07df4 100644
--- a/apps/webapp/app/components/primitives/FormButtons.tsx
+++ b/apps/webapp/app/components/primitives/FormButtons.tsx
@@ -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 : } {confirmButton}
+ {cancelButton ? cancelButton : } {confirmButton ?? null}
);
}
diff --git a/apps/webapp/app/components/primitives/LoadingBarDivider.tsx b/apps/webapp/app/components/primitives/LoadingBarDivider.tsx
index ee64ac4fc..52b4ff1da 100644
--- a/apps/webapp/app/components/primitives/LoadingBarDivider.tsx
+++ b/apps/webapp/app/components/primitives/LoadingBarDivider.tsx
@@ -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 (
-
+
);
diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx
index 864bdce17..63ba99f6d 100644
--- a/apps/webapp/app/components/primitives/Popover.tsx
+++ b/apps/webapp/app/components/primitives/Popover.tsx
@@ -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 ) {
+}: {
+ isOpen?: boolean;
+ variant?: PopoverVerticalEllipseVariant;
+} & React.ComponentPropsWithoutRef) {
+ const styles = popoverVerticalEllipseVariants[variant];
return (
-
+
);
}
diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx
index 830cd0118..df0bd88e0 100644
--- a/apps/webapp/app/components/primitives/Resizable.tsx
+++ b/apps/webapp/app/components/primitives/Resizable.tsx
@@ -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 */}
-
+
{/* Vertical orientation line indicator */}
-
+
{withHandle && (
<>
{/* Horizontal orientation dots (vertical arrangement) */}
diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx
index 9c9467389..c03492abc 100644
--- a/apps/webapp/app/components/primitives/Tooltip.tsx
+++ b/apps/webapp/app/components/primitives/Tooltip.tsx
@@ -87,7 +87,7 @@ function SimpleTooltip({
diff --git a/apps/webapp/app/components/primitives/charts/BigNumber.tsx b/apps/webapp/app/components/primitives/charts/BigNumber.tsx
deleted file mode 100644
index ab12e9326..000000000
--- a/apps/webapp/app/components/primitives/charts/BigNumber.tsx
+++ /dev/null
@@ -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 (
-
- {loading ? (
-
-
-
- ) : v !== undefined ? (
-
- {animate ? : v}
- {suffix && {suffix} }
-
- ) : (
- "–"
- )}
-
- );
-}
diff --git a/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx b/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx
new file mode 100644
index 000000000..f9d428041
--- /dev/null
+++ b/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx
@@ -0,0 +1,171 @@
+import type { OutputColumnMetadata } from "@internal/tsql";
+import { useMemo } from "react";
+import type {
+ BigNumberAggregationType,
+ BigNumberConfiguration,
+} from "~/components/metrics/QueryWidget";
+import { AnimatedNumber } from "../AnimatedNumber";
+import { Spinner } from "../Spinner";
+import { Paragraph } from "../Paragraph";
+
+interface BigNumberCardProps {
+ rows: Record[];
+ columns: OutputColumnMetadata[];
+ config: BigNumberConfiguration;
+ isLoading?: boolean;
+}
+
+/**
+ * Extracts numeric values from a specific column across all rows,
+ * optionally sorting them first.
+ */
+function extractColumnValues(
+ rows: Record[],
+ 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 (
+
+
+
+ );
+ }
+
+ if (result === null) {
+ return (
+
+
+ No data to display
+
+
+ );
+ }
+
+ const { displayValue, unitSuffix, decimalPlaces } = abbreviate
+ ? abbreviateValue(result)
+ : { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
+
+ return (
+
+
+
+ {prefix && {prefix}}
+
+ {(unitSuffix || suffix) && (
+
+ {unitSuffix}
+ {unitSuffix && suffix ? " " : ""}
+ {suffix}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/primitives/charts/Card.tsx b/apps/webapp/app/components/primitives/charts/Card.tsx
index c618b51d0..9249832b5 100644
--- a/apps/webapp/app/components/primitives/charts/Card.tsx
+++ b/apps/webapp/app/components/primitives/charts/Card.tsx
@@ -15,9 +15,22 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
);
};
-const CardHeader = ({ children }: { children: ReactNode }) => {
+const CardHeader = ({
+ children,
+ draggable,
+}: {
+ children: ReactNode;
+ draggable?: boolean;
+}) => {
return (
- {children}
+
+ {children}
+
);
};
diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx
index d71b72287..a34ce6675 100644
--- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx
+++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx
@@ -162,26 +162,27 @@ export function ChartBarRenderer({
domain={["auto", (dataMax: number) => dataMax * 1.15]}
{...yAxisPropsProp}
/>
- {/* Hide tooltip when legend is shown - legend displays hover data instead */}
- {!showLegend && (
-
- ) : (
-
- )
- }
- 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. */}
+ null
+ ) : tooltipLabelFormatter ? (
+
+ ) : (
+
+ )
+ }
+ labelFormatter={tooltipLabelFormatter}
+ allowEscapeViewBox={{ x: false, y: true }}
+ />
{/* Zoom selection area - rendered before bars to appear behind them */}
{enableZoom && zoom?.refAreaLeft !== null && zoom?.refAreaRight !== null && (
diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx
index 1ab1bb855..8d525aa1a 100644
--- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx
+++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx
@@ -1,8 +1,6 @@
import React, { useMemo } from "react";
import { useChartContext } from "./ChartContext";
import { useSeriesTotal } from "./ChartRoot";
-import { Button } from "../Buttons";
-import { Paragraph } from "../Paragraph";
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
@@ -132,11 +130,7 @@ export function ChartLegendCompound({
return (
{/* Total row */}
{/* Legend items - scrollable when scrollable prop is true */}
-
+
{legendItems.visible.map((item) => {
const total = currentData[item.dataKey] ?? 0;
const isActive = highlight.activeBarKey === item.dataKey;
@@ -211,7 +211,10 @@ export function ChartLegendCompound({
remainingCount={legendItems.remaining - 1}
/>
) : (
-
+
))}
@@ -225,23 +228,26 @@ type ViewAllDataRowProps = {
function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) {
return (
-
- {formEnvironments.error}
+
+ {formEnvironments.error ?? formEnvironments.initialError?.[""]?.[0]}
+
|