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 } from "@tabler/icons-react"; import { Type } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; 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 { useToast } from "~/components/primitives/Toast"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor"; import { 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 { getTaskIdentifiers } 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.dashboards.$dashboardKey/route"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; 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, }), getTaskIdentifiers(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, 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(); 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; const toast = useToast(); const handleSyncError = useCallback( (error: Error, action: string) => { const actionMessages: Record = { 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.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" }); }, [toast] ); // 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 ( ); }, [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; const dashboardMenu = (
); const filterAccessories = (
{widgetIsAtLimit ? ( <> You've exceeded your widget limit You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard. {widgetCanUpgrade ? ( Upgrade ) : ( Request more} defaultValue="help" /> )} ) : ( <> )} {dashboardMenu}
); return ( {totalWidgetCount === 0 && dashboardMenu}
{totalWidgetCount === 0 ? ( Add chart } > 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. ) : ( )}
} content={`${widgetLimitPercent}%`} />
{widgetIsAtLimit ? ( You've used all {widgetLimits.limit} of your available widgets. Upgrade your plan to enable more. ) : ( You've used {widgetLimits.used}/{widgetLimits.limit} of your charts )} {widgetCanUpgrade ? ( Upgrade ) : ( Request more…} defaultValue="help" /> )}
{/* Query Editor Sheet - opens on top of the dashboard */} !open && actions.closeEditor()}> {editorProps && ( )} {/* Add title dialog */} Add title
{ e.preventDefault(); if (newTitleValue.trim()) { actions.addTitleWidget(newTitleValue.trim()); setShowAddTitleDialog(false); } }} > setNewTitleValue(e.target.value)} placeholder="Section title" autoFocus />
{/* Widget limit dialog - triggered by hook when add/duplicate is blocked */} You've exceeded your widget limit You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard. {widgetCanUpgrade ? ( Upgrade ) : ( Request more} defaultValue="help" /> )}
); } 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 ( Rename dashboard
setNewTitle(e.target.value)} placeholder="Dashboard title" required autoFocus /> {isRenaming ? "Saving…" : "Save"} } cancelButton={ } />
); } 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 ( Delete dashboard
Are you sure you want to delete "{title}"? This action cannot be undone and all widgets on this dashboard will be permanently removed.
); }