From 7565d6d0ea7196897856565e482ac4d9e73bfbbb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 1 Feb 2026 15:39:45 +0000 Subject: [PATCH 001/131] Data format for Metric layouts --- .../presenters/v3/BuiltInDashboards.server.ts | 22 +++ .../v3/MetricDashboardPresenter.server.ts | 102 ++++++++++++ apps/webapp/package.json | 1 + .../migration.sql | 25 +++ .../database/prisma/schema.prisma | 145 +++++++++++------- pnpm-lock.yaml | 63 +++++++- 6 files changed, 295 insertions(+), 63 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts create mode 100644 apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts create mode 100644 internal-packages/database/prisma/migrations/20260201130503_metrics_dashboard_table_created/migration.sql diff --git a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts new file mode 100644 index 0000000000..730893e38e --- /dev/null +++ b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts @@ -0,0 +1,22 @@ +import { type BuiltInDashboard } from "./MetricDashboardPresenter.server"; + +const overviewDashboard: BuiltInDashboard = { + key: "overview", + title: "Overview", + layout: { + version: "1", + layout: [], + widgets: {}, + }, +}; + +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; +} diff --git a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts new file mode 100644 index 0000000000..5aeb3b073c --- /dev/null +++ b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts @@ -0,0 +1,102 @@ +import { BasePresenter } from "./basePresenter.server"; +import type { QueryScope } from "~/services/queryService.server"; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; +import { builtInDashboard } from "./BuiltInDashboards.server"; + +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[]; +}; + +const LayoutItem = z.object({ + i: z.string(), + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), +}); + +const Widget = z.object({ + query: z.string(), + display: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("table"), + }), + z.object({ + type: z.literal("chart"), + chartType: z.union([z.literal("line"), z.literal("bar")]), + }), + ]), +}); + +const DashboardLayout = z.discriminatedUnion("version", [ + z.object({ + version: z.literal("1"), + layout: z.array(LayoutItem), + widgets: z.record(Widget), + }), +]); + +export type DashboardLayout = z.infer; + +export type CustomDashboard = { + id: string; + title: string; + layout: DashboardLayout; +}; + +export type BuiltInDashboard = { + key: string; + title: string; + layout: DashboardLayout; +}; + +/** Returns the dashboard layout */ +export class MetricDashboardPresenter extends BasePresenter { + public async customDashboard({ + dashboardId, + organizationId, + }: { + dashboardId: string; + organizationId: string; + }): Promise { + const dashboard = await this._replica.metricsDashboard.findFirst({ + where: { id: dashboardId, organizationId }, + }); + if (!dashboard) { + throw new Error("No dashboard found"); + } + + const layout = this.#getLayout(dashboard.layout); + + return { + id: dashboardId, + title: dashboard.title, + layout, + }; + } + + public async builtInDashboard(key: string): Promise { + return builtInDashboard(key); + } + + #getLayout(layoutData: string): DashboardLayout { + const parsedLayout = DashboardLayout.safeParse(layoutData); + if (!parsedLayout.success) { + throw fromZodError(parsedLayout.error); + } + + return parsedLayout.data; + } +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index e2ea2cd5e2..992722bb29 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -186,6 +186,7 @@ "react-collapse": "^5.1.1", "react-day-picker": "^9.13.0", "react-dom": "^18.2.0", + "react-grid-layout": "^2.2.2", "react-hotkeys-hook": "^4.4.1", "react-popper": "^2.3.0", "react-resizable-panels": "^2.0.9", diff --git a/internal-packages/database/prisma/migrations/20260201130503_metrics_dashboard_table_created/migration.sql b/internal-packages/database/prisma/migrations/20260201130503_metrics_dashboard_table_created/migration.sql new file mode 100644 index 0000000000..321b3148ca --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260201130503_metrics_dashboard_table_created/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE + "public"."MetricsDashboard" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "projectId" TEXT, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "layout" TEXT NOT NULL, + CONSTRAINT "MetricsDashboard_pkey" PRIMARY KEY ("id") + ); + +-- CreateIndex +CREATE INDEX "MetricsDashboard_projectId_createdAt_idx" ON "public"."MetricsDashboard" ("projectId", "createdAt" DESC); + +-- AddForeignKey +ALTER TABLE "public"."MetricsDashboard" ADD CONSTRAINT "MetricsDashboard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."MetricsDashboard" ADD CONSTRAINT "MetricsDashboard_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."MetricsDashboard" ADD CONSTRAINT "MetricsDashboard_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "public"."User" ("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index a62980cde9..611eb4b338 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -63,6 +63,7 @@ model User { impersonationsPerformed ImpersonationAuditLog[] @relation("ImpersonationAdmin") impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget") customerQueries CustomerQuery[] + metricsDashboards MetricsDashboard[] } model MfaBackupCode { @@ -223,6 +224,7 @@ model Organization { workerInstances WorkerInstance[] githubAppInstallations GithubAppInstallation[] customerQueries CustomerQuery[] + metricsDashboards MetricsDashboard[] } model OrgMember { @@ -384,32 +386,33 @@ model Project { /// The master queues they are allowed to use (impacts what they can set as default and trigger runs with) allowedWorkerQueues String[] @default([]) @map("allowedMasterQueues") - environments RuntimeEnvironment[] - backgroundWorkers BackgroundWorker[] - backgroundWorkerTasks BackgroundWorkerTask[] - taskRuns TaskRun[] - runTags TaskRunTag[] - taskQueues TaskQueue[] - environmentVariables EnvironmentVariable[] - checkpoints Checkpoint[] - WorkerDeployment WorkerDeployment[] - CheckpointRestoreEvent CheckpointRestoreEvent[] - taskSchedules TaskSchedule[] - alertChannels ProjectAlertChannel[] - alerts ProjectAlert[] - alertStorages ProjectAlertStorage[] - bulkActionGroups BulkActionGroup[] - BackgroundWorkerFile BackgroundWorkerFile[] - waitpoints Waitpoint[] - taskRunWaitpoints TaskRunWaitpoint[] - taskRunCheckpoints TaskRunCheckpoint[] - waitpointTags WaitpointTag[] - connectedGithubRepository ConnectedGithubRepository? - organizationProjectIntegration OrganizationProjectIntegration[] - customerQueries CustomerQuery[] + environments RuntimeEnvironment[] + backgroundWorkers BackgroundWorker[] + backgroundWorkerTasks BackgroundWorkerTask[] + taskRuns TaskRun[] + runTags TaskRunTag[] + taskQueues TaskQueue[] + environmentVariables EnvironmentVariable[] + checkpoints Checkpoint[] + WorkerDeployment WorkerDeployment[] + CheckpointRestoreEvent CheckpointRestoreEvent[] + taskSchedules TaskSchedule[] + alertChannels ProjectAlertChannel[] + alerts ProjectAlert[] + alertStorages ProjectAlertStorage[] + bulkActionGroups BulkActionGroup[] + BackgroundWorkerFile BackgroundWorkerFile[] + waitpoints Waitpoint[] + taskRunWaitpoints TaskRunWaitpoint[] + taskRunCheckpoints TaskRunCheckpoint[] + waitpointTags WaitpointTag[] + connectedGithubRepository ConnectedGithubRepository? + organizationProjectIntegration OrganizationProjectIntegration[] + customerQueries CustomerQuery[] buildSettings Json? taskScheduleInstances TaskScheduleInstance[] + metricsDashboards MetricsDashboard[] } enum ProjectVersion { @@ -1713,7 +1716,7 @@ model EnvironmentVariableValue { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - version Int @default(1) + version Int @default(1) lastUpdatedBy Json? @@unique([variableId, environmentId]) @@ -1829,10 +1832,10 @@ model WorkerDeployment { worker BackgroundWorker? @relation(fields: [workerId], references: [id], onDelete: Cascade, onUpdate: Cascade) workerId String? @unique - triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade) - triggeredById String? - triggeredVia String? - commitSHA String? + triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade) + triggeredById String? + triggeredVia String? + commitSHA String? startedAt DateTime? installedAt DateTime? @@ -1851,10 +1854,10 @@ model WorkerDeployment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - promotions WorkerDeploymentPromotion[] - alerts ProjectAlert[] - workerInstance WorkerInstance[] - integrationDeployments IntegrationDeployment[] + promotions WorkerDeploymentPromotion[] + alerts ProjectAlert[] + workerInstance WorkerInstance[] + integrationDeployments IntegrationDeployment[] @@unique([projectId, shortCode]) @@unique([environmentId, version]) @@ -2095,8 +2098,8 @@ model OrganizationIntegration { friendlyId String @unique - service IntegrationService - externalOrganizationId String? /// Identifier for external, integration's organization (e.g. Vercel's team) + service IntegrationService + externalOrganizationId String? /// Identifier for external, integration's organization (e.g. Vercel's team) integrationData Json @@ -2106,33 +2109,33 @@ model OrganizationIntegration { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) organizationId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deletedAt DateTime? - alertChannels ProjectAlertChannel[] - organizationProjectIntegration OrganizationProjectIntegration[] + alertChannels ProjectAlertChannel[] + organizationProjectIntegration OrganizationProjectIntegration[] @@index([externalOrganizationId]) } model OrganizationProjectIntegration { - id String @id @default(cuid()) - - organizationIntegration OrganizationIntegration @relation(fields: [organizationIntegrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) - organizationIntegrationId String - - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) - projectId String - - externalEntityId String /// Identifier for webhooks, for example Vercel's projectId - integrationData Json /// Save useful data like config or external entity name - installedBy String? /// UserId who installed the integration + id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + organizationIntegration OrganizationIntegration @relation(fields: [organizationIntegrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationIntegrationId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + externalEntityId String /// Identifier for webhooks, for example Vercel's projectId + integrationData Json /// Save useful data like config or external entity name + installedBy String? /// UserId who installed the integration + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deletedAt DateTime? - + @@index([projectId]) @@index([projectId, organizationIntegrationId]) @@index([externalEntityId]) @@ -2523,19 +2526,47 @@ model CustomerQuery { } model IntegrationDeployment { - id String @id @default(cuid()) - + id String @id @default(cuid()) + integrationName String /// For example Vercel integrationDeploymentId String /// External ID commitSHA String deploymentId String? status String? /// External deployment status - workerDeployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade) + workerDeployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([commitSHA]) @@index([deploymentId]) } + +/// A user-defined metrics dashboard +model MetricsDashboard { + id String @id @default(cuid()) + + title String + description String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String? + + /// Who created the dashboard + owner User @relation(fields: [ownerId], references: [id], onDelete: SetNull, onUpdate: Cascade) + ownerId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// JSON that defines the config, queries, layout and config of all widgets. + /// There will be a version field for the format. + layout String + + /// Fast lookup for the list + @@index([projectId, createdAt(sort: Desc)]) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c88884a54..0423cb0697 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-grid-layout: + specifier: ^2.2.2 + version: 2.2.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-hotkeys-hook: specifier: ^4.4.1 version: 4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1095,7 +1098,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -13804,6 +13807,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@4.0.3: + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} + fast-equals@5.0.1: resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} engines: {node: '>=6.0.0'} @@ -17616,6 +17622,12 @@ packages: peerDependencies: react: ^19.1.0 + react-draggable@4.5.0: + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-email@2.1.2: resolution: {integrity: sha512-HBHhpzEE5es9YUoo7VSj6qy1omjwndxf3/Sb44UJm/uJ2AjmqALo2yryux0CjW9QAVfitc9rxHkLvIb9H87QQw==} engines: {node: '>=18.0.0'} @@ -17624,6 +17636,12 @@ packages: react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + react-grid-layout@2.2.2: + resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-hotkeys-hook@4.4.1: resolution: {integrity: sha512-sClBMBioFEgFGYLTWWRKvhxcCx1DRznd+wkFHwQZspnRBkHTgruKIHptlK/U/2DPX8BhHoRGzpMVWUXMmdZlmw==} peerDependencies: @@ -17682,6 +17700,12 @@ packages: react: ^16.14.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 + react-resizable@3.1.3: + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} + peerDependencies: + react: '>= 16.3' + react-dom: '>= 16.3' + react-router-dom@6.17.0: resolution: {integrity: sha512-qWHkkbXQX+6li0COUUPKAUkxjNNqPJuiBd27dVwQGDNsuFBdMbrS6UZ0CLYc4CsbdLYTckn4oB4tGDuPZpPhaQ==} engines: {node: '>=14.0.0'} @@ -34677,6 +34701,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@4.0.3: {} + fast-equals@5.0.1: {} fast-fifo@1.3.2: {} @@ -39128,7 +39154,14 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): + react-draggable@4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39165,8 +39198,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3 - socket.io-client: 4.7.3 + socket.io: 4.7.3(bufferutil@4.0.9) + socket.io-client: 4.7.3(bufferutil@4.0.9) sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -39189,6 +39222,17 @@ snapshots: react-fast-compare@3.2.2: {} + react-grid-layout@2.2.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + clsx: 2.1.1 + fast-equals: 4.0.3 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-resizable: 3.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + resize-observer-polyfill: 1.5.1 + react-hotkeys-hook@4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: react: 18.2.0 @@ -39307,6 +39351,13 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + react-resizable@3.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-draggable: 4.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-router-dom@6.17.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@remix-run/router': 1.10.0 @@ -40348,7 +40399,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3: + socket.io-client@4.7.3(bufferutil@4.0.9): dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40377,7 +40428,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3: + socket.io@4.7.3(bufferutil@4.0.9): dependencies: accepts: 1.3.8 base64id: 2.0.0 From ed1201ef9fda8ce592e6642f11bbdf4e5a9a53d7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 29 Jan 2026 22:06:45 +0000 Subject: [PATCH 002/131] Refactored some code from the action into the service The action had a lot of logic in and by moving it into the service we can re-use the service for API querying --- .../route.tsx | 104 ++--------- .../app/services/queryService.server.ts | 162 +++++++++++++----- 2 files changed, 142 insertions(+), 124 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx index 72020d8adf..8618dfc578 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -71,7 +71,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { QueryPresenter, type QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server"; import type { action as titleAction } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title"; import { getLimit } from "~/services/platform.v3.server"; -import { executeQuery, type QueryScope } from "~/services/queryService.server"; +import { executeQuery, getDefaultPeriod, type QueryScope } from "~/services/queryService.server"; import { requireUser } from "~/services/session.server"; import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport"; import { EnvironmentParamSchema, organizationBillingPath } from "~/utils/pathBuilder"; @@ -144,15 +144,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); }; -async function getDefaultPeriod(organizationId: string): Promise { - const idealDefaultPeriodDays = 7; - const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30); - if (maxQueryPeriod < idealDefaultPeriodDays) { - return `${maxQueryPeriod}d`; - } - return `${idealDefaultPeriodDays}d`; -} - const ActionSchema = z.object({ query: z.string().min(1, "Query is required"), scope: z.enum(["environment", "project", "organization"]), @@ -257,87 +248,29 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const isAdmin = user.admin || user.isImpersonating; const explain = explainParam === "true" && isAdmin; - // Build time filter fallback for triggered_at column - const defaultPeriod = await getDefaultPeriod(project.organizationId); - const timeFilter = timeFilters({ - period: period ?? undefined, - from: from ?? undefined, - to: to ?? undefined, - defaultPeriod, - }); - - // Calculate the effective "from" date the user is requesting (for period clipping check) - // This is null only when the user specifies just a "to" date (rare case) - let requestedFromDate: Date | null = null; - if (timeFilter.from) { - requestedFromDate = new Date(timeFilter.from); - } else if (!timeFilter.to) { - // Period specified (or default) - calculate from now - const periodMs = parse(timeFilter.period ?? defaultPeriod) ?? 7 * 24 * 60 * 60 * 1000; - requestedFromDate = new Date(Date.now() - periodMs); - } - - // Build the fallback WHERE condition based on what the user specified - let triggeredAtFallback: WhereClauseCondition; - if (timeFilter.from && timeFilter.to) { - triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to }; - } else if (timeFilter.from) { - triggeredAtFallback = { op: "gte", value: timeFilter.from }; - } else if (timeFilter.to) { - triggeredAtFallback = { op: "lte", value: timeFilter.to }; - } else { - triggeredAtFallback = { op: "gte", value: requestedFromDate! }; - } - - const maxQueryPeriod = await getLimit(project.organizationId, "queryPeriodDays", 30); - const maxQueryPeriodDate = new Date(Date.now() - maxQueryPeriod * 24 * 60 * 60 * 1000); - - // Check if the requested time period exceeds the plan limit - const periodClipped = requestedFromDate !== null && requestedFromDate < maxQueryPeriodDate; - - // Force tenant isolation and time period limits - const enforcedWhereClause = { - organization_id: { op: "eq", value: project.organizationId }, - project_id: - scope === "project" || scope === "environment" ? { op: "eq", value: project.id } : undefined, - environment_id: scope === "environment" ? { op: "eq", value: environment.id } : undefined, - triggered_at: { op: "gte", value: maxQueryPeriodDate }, - } satisfies Record; - try { - const [error, result, queryId] = await executeQuery({ + const queryResult = await executeQuery({ name: "query-page", query, - schema: z.record(z.any()), - tableSchema: querySchemas, - transformValues: true, scope, organizationId: project.organizationId, projectId: project.id, environmentId: environment.id, explain, - enforcedWhereClause, - whereClauseFallback: { - triggered_at: triggeredAtFallback, - }, + period, + from, + to, history: { source: "DASHBOARD", userId: user.id, skip: user.isImpersonating, - timeFilter: { - // Save the effective period used for the query (timeFilters() handles defaults) - // Only save period if no custom from/to range was specified - period: timeFilter.from || timeFilter.to ? undefined : timeFilter.period, - from: timeFilter.from, - to: timeFilter.to, - }, }, }); - if (error) { + if (!queryResult.success) { return typedjson( { - error: error.message, + error: queryResult.error.message, rows: null, columns: null, stats: null, @@ -354,15 +287,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return typedjson({ error: null, - rows: result.rows, - columns: result.columns, - stats: result.stats, - hiddenColumns: result.hiddenColumns ?? null, - reachedMaxRows: result.reachedMaxRows, - explainOutput: result.explainOutput ?? null, - generatedSql: result.generatedSql ?? null, - queryId, - periodClipped: periodClipped ? maxQueryPeriod : null, + rows: queryResult.result.rows, + columns: queryResult.result.columns, + stats: queryResult.result.stats, + hiddenColumns: queryResult.result.hiddenColumns ?? null, + reachedMaxRows: queryResult.result.reachedMaxRows, + explainOutput: queryResult.result.explainOutput ?? null, + generatedSql: queryResult.result.generatedSql ?? null, + queryId: queryResult.queryId, + periodClipped: queryResult.periodClipped, + maxQueryPeriod: queryResult.maxQueryPeriod, }); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error executing query"; @@ -1036,7 +970,7 @@ function QueryResultsCallouts({ organizationSlug, }: { hiddenColumns: string[] | null | undefined; - periodClipped: number | null | undefined; + periodClipped: number | null; organizationSlug: string; }) { const hasCallouts = (hiddenColumns && hiddenColumns.length > 0) || periodClipped; @@ -1076,7 +1010,7 @@ function QueryResultsCallouts({ function hasQueryResultsCallouts( hiddenColumns: string[] | null | undefined, - periodClipped: number | null | undefined + periodClipped: number | null ): boolean { return (hiddenColumns && hiddenColumns.length > 0) || !!periodClipped; } diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts index 1d5af9e001..a6d9c7cd37 100644 --- a/apps/webapp/app/services/queryService.server.ts +++ b/apps/webapp/app/services/queryService.server.ts @@ -8,7 +8,7 @@ import { } from "@internal/clickhouse"; import type { CustomerQuerySource } from "@trigger.dev/database"; import type { TableSchema, WhereClauseCondition } from "@internal/tsql"; -import { type z } from "zod"; +import { z } from "zod"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { clickhouseClient } from "./clickhouseInstance.server"; @@ -17,6 +17,10 @@ import { DEFAULT_ORG_CONCURRENCY_LIMIT, GLOBAL_CONCURRENCY_LIMIT, } from "./queryConcurrencyLimiter.server"; +import { getLimit } from "./platform.v3.server"; +import { timeFilters } from "~/components/runs/v3/SharedFilters"; +import parse from "parse-duration"; +import { querySchemas } from "~/v3/querySchemas"; export type { TableSchema, TSQLQueryResult }; @@ -56,14 +60,16 @@ function getDefaultClickhouseSettings(): ClickHouseSettings { export type ExecuteQueryOptions = Omit< ExecuteTSQLOptions, - "tableSchema" | "fieldMappings" + "tableSchema" | "fieldMappings" | "enforcedWhereClause" | "whereClauseFallback" | "schema" > & { organizationId: string; - projectId?: string; - environmentId?: string; - tableSchema: TableSchema[]; + projectId: string; + environmentId: string; /** The scope of the query - determines tenant isolation */ scope: QueryScope; + period?: string | null; + from?: string | null; + to?: string | null; /** History options for saving query to billing/audit */ history?: { /** Where the query originated from */ @@ -72,15 +78,6 @@ export type ExecuteQueryOptions = Omit< userId?: string | null; /** Skip saving to history (e.g., when impersonating) */ skip?: boolean; - /** Time filter settings to save with the query */ - timeFilter?: { - /** Period like "7d", "24h", etc. */ - period?: string; - /** Custom start date */ - from?: Date; - /** Custom end date */ - to?: Date; - }; }; /** Custom per-org concurrency limit (overrides default) */ customOrgConcurrencyLimit?: number; @@ -90,8 +87,23 @@ export type ExecuteQueryOptions = Omit< * Extended result type that includes the optional queryId when saved to history */ export type ExecuteQueryResult = - | [error: Error, result: null, queryId: null] - | [error: null, result: T, queryId: string | null]; + | { + success: true; + result: T; + queryId: string | null; + periodClipped: number | null; + maxQueryPeriod: number; + } + | { success: false; error: Error }; + +export async function getDefaultPeriod(organizationId: string): Promise { + const idealDefaultPeriodDays = 7; + const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30); + if (maxQueryPeriod < idealDefaultPeriodDays) { + return `${maxQueryPeriod}d`; + } + return `${idealDefaultPeriodDays}d`; +} /** * Execute a TSQL query against ClickHouse with tenant isolation @@ -102,14 +114,15 @@ export async function executeQuery( options: ExecuteQueryOptions ): Promise>[1], null>>> { const { + period, + from, + to, scope, organizationId, projectId, environmentId, - enforcedWhereClause, history, customOrgConcurrencyLimit, - whereClauseFallback, ...baseOptions } = options; @@ -118,20 +131,67 @@ export async function executeQuery( const orgLimit = customOrgConcurrencyLimit ?? DEFAULT_ORG_CONCURRENCY_LIMIT; // Acquire concurrency slot - const acquireResult = await queryConcurrencyLimiter.acquire({ - key: organizationId, - requestId, - keyLimit: orgLimit, - globalLimit: GLOBAL_CONCURRENCY_LIMIT, - }); + const acquireResult = await queryConcurrencyLimiter.acquire({ + key: organizationId, + requestId, + keyLimit: orgLimit, + globalLimit: GLOBAL_CONCURRENCY_LIMIT, + }); - if (!acquireResult.success) { - const errorMessage = - acquireResult.reason === "key_limit" - ? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.` - : "We're experiencing a lot of queries at the moment. Please try again later."; - return [new QueryError(errorMessage, { query: options.query }), null, null]; - } + if (!acquireResult.success) { + const errorMessage = + acquireResult.reason === "key_limit" + ? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.` + : "We're experiencing a lot of queries at the moment. Please try again later."; + return { success: false, error: new QueryError(errorMessage, { query: options.query }) }; + } + + // Build time filter fallback for triggered_at column + const defaultPeriod = await getDefaultPeriod(organizationId); + const timeFilter = timeFilters({ + period: period ?? undefined, + from: from ?? undefined, + to: to ?? undefined, + defaultPeriod, + }); + + // Calculate the effective "from" date the user is requesting (for period clipping check) + // This is null only when the user specifies just a "to" date (rare case) + let requestedFromDate: Date | null = null; + if (timeFilter.from) { + requestedFromDate = new Date(timeFilter.from); + } else if (!timeFilter.to) { + // Period specified (or default) - calculate from now + const periodMs = parse(timeFilter.period ?? defaultPeriod) ?? 7 * 24 * 60 * 60 * 1000; + requestedFromDate = new Date(Date.now() - periodMs); + } + + // Build the fallback WHERE condition based on what the user specified + let triggeredAtFallback: WhereClauseCondition; + if (timeFilter.from && timeFilter.to) { + triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to }; + } else if (timeFilter.from) { + triggeredAtFallback = { op: "gte", value: timeFilter.from }; + } else if (timeFilter.to) { + triggeredAtFallback = { op: "lte", value: timeFilter.to }; + } else { + triggeredAtFallback = { op: "gte", value: requestedFromDate! }; + } + + const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30); + const maxQueryPeriodDate = new Date(Date.now() - maxQueryPeriod * 24 * 60 * 60 * 1000); + + // Check if the requested time period exceeds the plan limit + const periodClipped = requestedFromDate !== null && requestedFromDate < maxQueryPeriodDate; + + // Force tenant isolation and time period limits + const enforcedWhereClause = { + organization_id: { op: "eq", value: organizationId }, + project_id: + scope === "project" || scope === "environment" ? { op: "eq", value: projectId } : undefined, + environment_id: scope === "environment" ? { op: "eq", value: environmentId } : undefined, + triggered_at: { op: "gte", value: maxQueryPeriodDate }, + } satisfies Record; try { // Build field mappings for project_ref → project_id and environment_id → slug translation @@ -152,9 +212,14 @@ export async function executeQuery( const result = await executeTSQL(clickhouseClient.reader, { ...baseOptions, + schema: z.record(z.any()), + tableSchema: querySchemas, + transformValues: true, enforcedWhereClause, fieldMappings, - whereClauseFallback, + whereClauseFallback: { + triggered_at: triggeredAtFallback, + }, clickhouseSettings: { ...getDefaultClickhouseSettings(), ...baseOptions.clickhouseSettings, // Allow caller overrides if needed @@ -167,7 +232,7 @@ export async function executeQuery( // If query failed, return early with no queryId if (result[0] !== null) { - return [result[0], null, null]; + return { success: false, error: result[0] }; } let queryId: string | null = null; @@ -183,10 +248,23 @@ export async function executeQuery( userId: history.userId ?? null, }, orderBy: { createdAt: "desc" }, - select: { id: true, query: true, scope: true, filterPeriod: true, filterFrom: true, filterTo: true }, + select: { + id: true, + query: true, + scope: true, + filterPeriod: true, + filterFrom: true, + filterTo: true, + }, }); - const timeFilter = history.timeFilter; + // Save the effective period used for the query (timeFilters() handles defaults) + // Only save period if no custom from/to range was specified + const historyTimeFilter = { + period: timeFilter.from || timeFilter.to ? undefined : timeFilter.period, + from: timeFilter.from, + to: timeFilter.to, + }; const isDuplicate = lastQuery && lastQuery.query === options.query && @@ -209,16 +287,22 @@ export async function executeQuery( projectId: scope === "project" || scope === "environment" ? projectId : null, environmentId: scope === "environment" ? environmentId : null, userId: history.userId ?? null, - filterPeriod: history.timeFilter?.period ?? null, - filterFrom: history.timeFilter?.from ?? null, - filterTo: history.timeFilter?.to ?? null, + filterPeriod: historyTimeFilter?.period ?? null, + filterFrom: historyTimeFilter?.from ?? null, + filterTo: historyTimeFilter?.to ?? null, }, }); queryId = created.id; } } - return [null, result[1], queryId]; + return { + success: true, + result: result[1], + queryId, + periodClipped: periodClipped ? maxQueryPeriod : null, + maxQueryPeriod, + }; } finally { // Always release the concurrency slot await queryConcurrencyLimiter.release({ From 3d99540d4ad53e6e0192df4890ae9eecf4662be8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 1 Feb 2026 17:22:38 +0000 Subject: [PATCH 003/131] QueryWidget that can be used on the Query page and on Metrics dashboards --- .../app/components/code/ChartConfigPanel.tsx | 123 ++++++------- .../app/components/code/QueryResultsChart.tsx | 16 +- .../app/components/code/TSQLResultsTable.tsx | 18 +- .../app/components/metrics/QueryWidget.tsx | 172 ++++++++++++++++++ .../route.tsx | 111 ++++------- 5 files changed, 282 insertions(+), 158 deletions(-) create mode 100644 apps/webapp/app/components/metrics/QueryWidget.tsx diff --git a/apps/webapp/app/components/code/ChartConfigPanel.tsx b/apps/webapp/app/components/code/ChartConfigPanel.tsx index 7d563e781d..3c21819a06 100644 --- a/apps/webapp/app/components/code/ChartConfigPanel.tsx +++ b/apps/webapp/app/components/code/ChartConfigPanel.tsx @@ -2,26 +2,15 @@ import type { OutputColumnMetadata } from "@internal/clickhouse"; import { BarChart, LineChart, Plus, XIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { cn } from "~/utils/cn"; -import { Header3 } from "../primitives/Headers"; import { Paragraph } from "../primitives/Paragraph"; import { Select, SelectItem } from "../primitives/Select"; import { Switch } from "../primitives/Switch"; 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"; export const defaultChartConfig: ChartConfiguration = { chartType: "bar", @@ -329,60 +318,58 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart ) : (
{/* Always show at least one dropdown, even if yAxisColumns is empty */} - {(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map( - (col, index) => ( -
- { + 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 }); + }} + 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) => ( + + + {item.label} + + + + )) + } + + {index > 0 && ( + - )} -
- ) - )} + + + )} +
+ ))} {/* Add another series button - only show when we have at least one series and not grouped */} {config.yAxisColumns.length > 0 && @@ -439,9 +426,7 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart {/* Group By - disabled when multiple series are selected */} {config.yAxisColumns.length > 1 ? ( - - Not available with multiple series - + Not available with multiple series ) : ( setTitle(e.target.value)} + placeholder="My Dashboard" + required + /> + + + + setDescription(e.target.value)} + placeholder="Dashboard description" + /> + + + {isLoading ? "Creating..." : "Create"} + + } + cancelButton={ + + + + } + /> + + + ); +} + function AnimatedChevron({ isHovering, isCollapsed, diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 1f19ffe487..904c3f0c25 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -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,30 @@ export function SideMenuSection({
{/* Header - fades out when sidebar is collapsed */} -

{title}

- - - +

{title}

+ + + +
+ {headerAction && ( +
{headerAction}
+ )} {/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */} ["customDashboards"][number]; + +export function useCustomDashboards(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + return data?.customDashboards ?? []; +} diff --git a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts index 600c2ef4e5..d94f4ec292 100644 --- a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts @@ -55,7 +55,6 @@ export type BuiltInDashboard = { key: string; title: string; layout: DashboardLayout; - defaultPeriod: string; }; /** Returns the dashboard layout */ @@ -86,13 +85,7 @@ export class MetricDashboardPresenter extends BasePresenter { }; } - public async builtInDashboard({ - organizationId, - key, - }: { - organizationId: string; - key: string; - }): Promise { + public async builtInDashboard({ organizationId, key }: { organizationId: string; key: string }) { const defaultPeriod = await getDefaultPeriod(organizationId); const dashboard = builtInDashboard(key); return { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 8a22bdefc9..7178e26462 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -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,17 @@ 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, + }, + orderBy: { createdAt: "desc" }, + }), ]); let hasExceededFreeTier = false; @@ -106,6 +115,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environment, isImpersonating: !!impersonationId, currentPlan: { ...plan, v3Usage: { ...usage, hasExceededFreeTier, usagePercentage } }, + customDashboards, }); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.create.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.create.tsx new file mode 100644 index 0000000000..7eeb3aebe2 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.create.tsx @@ -0,0 +1,64 @@ +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { findProjectBySlug } from "~/models/project.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 }); + } + + 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 } + ) + ); +}; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index b7c357521e..24ac360c89 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -283,6 +283,15 @@ export function v3CustomDashboardPath( return `${v3EnvironmentPath(organization, project, environment)}/metrics/custom/${dashboard.friendlyId}`; } +export function v3BuiltInDashboardPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + key: string +) { + return `${v3EnvironmentPath(organization, project, environment)}/metrics/${key}`; +} + export function v3TestTaskPath( organization: OrgForPath, project: ProjectForPath, From 114b9f0d31ea7dd6c2cb4e8357e25775da9f4f74 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 1 Feb 2026 21:40:19 -0800 Subject: [PATCH 018/131] Add query table/chart to custom dashboard --- .../metrics/SaveToDashboardDialog.tsx | 137 ++++++++++++++ .../route.tsx | 43 +++++ ...ram.dashboards.$dashboardId.add-widget.tsx | 174 ++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx diff --git a/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx b/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx new file mode 100644 index 0000000000..41086e30b3 --- /dev/null +++ b/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx @@ -0,0 +1,137 @@ +import { ChartBarSquareIcon } from "@heroicons/react/20/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useNavigation } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useCustomDashboards } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { Button } from "../primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog"; +import { FormButtons } from "../primitives/FormButtons"; +import { Paragraph } from "../primitives/Paragraph"; +import { cn } from "~/utils/cn"; +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 navigation = useNavigation(); + + const [selectedDashboardId, setSelectedDashboardId] = useState( + customDashboards.length > 0 ? customDashboards[0].friendlyId : null + ); + + // Build the form action URL + const formAction = selectedDashboardId + ? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/add-widget` + : ""; + + const isLoading = navigation.formAction === formAction && navigation.state === "submitting"; + + // Close dialog when navigation completes (redirect is happening) + useEffect(() => { + if (navigation.formAction === formAction && navigation.state === "loading") { + onOpenChange(false); + } + }, [navigation.formAction, navigation.state, formAction, onOpenChange]); + + // Update selection if dashboards change + useEffect(() => { + if (customDashboards.length > 0 && !selectedDashboardId) { + setSelectedDashboardId(customDashboards[0].friendlyId); + } + }, [customDashboards, selectedDashboardId]); + + if (customDashboards.length === 0) { + return ( + + + Save to Dashboard +
+ + You don't have any custom dashboards yet. Create one first from the sidebar menu. + + + + + } + /> +
+
+
+ ); + } + + return ( + + + Save to Dashboard +
+ + + + +
+ + Select a dashboard to add this widget to: + +
+ {customDashboards.map((dashboard) => ( + + ))} +
+
+ + + {isLoading ? "Saving..." : "Save"} + + } + cancelButton={ + + + + } + /> + +
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx index af063719cd..c47f3c24bb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -2,6 +2,7 @@ import { ArrowDownTrayIcon, ArrowsPointingOutIcon, ArrowTrendingUpIcon, + BookmarkIcon, ClipboardIcon, TableCellsIcon, } from "@heroicons/react/20/solid"; @@ -79,6 +80,7 @@ import { QueryHistoryPopover } from "./QueryHistoryPopover"; import type { AITimeFilter } from "./types"; import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; import { ChartConfiguration, QueryWidget } from "~/components/metrics/QueryWidget"; +import { SaveToDashboardDialog } from "~/components/metrics/SaveToDashboardDialog"; /** Convert a Date or ISO string to ISO string format */ function toISOString(value: Date | string): string { @@ -537,6 +539,9 @@ export default function Page() { const [sidebarTab, setSidebarTab] = useState("ai"); const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null); + // Save to dashboard dialog state + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + // Title generation state const titleFetcher = useFetcher(); const isTitleLoading = titleFetcher.state !== "idle"; @@ -800,6 +805,18 @@ export default function Page() { prettyFormatting, sorting: [], }} + accessory={ + setIsSaveDialogOpen(true)} + /> + } + content="Save to dashboard" + /> + } /> @@ -833,6 +850,7 @@ export default function Page() { onChartConfigChange={handleChartConfigChange} queryTitle={queryTitle} isTitleLoading={isTitleLoading} + onSaveClick={() => setIsSaveDialogOpen(true)} /> ) : ( @@ -873,6 +891,17 @@ export default function Page() { + ); } @@ -1023,6 +1052,7 @@ function ResultsChart({ onChartConfigChange, queryTitle, isTitleLoading, + onSaveClick, }: { rows: Record[]; columns: OutputColumnMetadata[]; @@ -1030,6 +1060,7 @@ function ResultsChart({ onChartConfigChange: (config: ChartConfiguration) => void; queryTitle: string | null; isTitleLoading: boolean; + onSaveClick: () => void; }) { return ( <> @@ -1046,6 +1077,18 @@ function ResultsChart({ type: "chart", ...chartConfig, }} + accessory={ + + } + content="Save to dashboard" + /> + } /> diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx new file mode 100644 index 0000000000..5cbb5aafbc --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx @@ -0,0 +1,174 @@ +import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { nanoid } from "nanoid"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; +import { findProjectBySlug } from "~/models/project.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; + +const AddWidgetSchema = z.object({ + 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 ParamsSchema = EnvironmentParamSchema.extend({ + dashboardId: z.string(), +}); + +// Layout item schema for parsing existing layout +const LayoutItemSchema = z.object({ + i: z.string(), + x: z.number(), + y: z.number(), + w: z.number(), + h: z.number(), +}); + +const WidgetSchema = z.object({ + title: z.string(), + query: z.string(), + display: QueryWidgetConfig, +}); + +const DashboardLayoutSchema = z.object({ + version: z.literal("1"), + layout: z.array(LayoutItemSchema), + widgets: z.record(WidgetSchema), +}); + +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 }); + } + + // 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 rawData = { + 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; + + // Parse existing layout + let existingLayout: z.infer; + try { + const parsed = JSON.parse(dashboard.layout); + const layoutResult = DashboardLayoutSchema.safeParse(parsed); + if (!layoutResult.success) { + // If parsing fails, start with empty layout + existingLayout = { + version: "1", + layout: [], + widgets: {}, + }; + } else { + existingLayout = layoutResult.data; + } + } catch { + existingLayout = { + version: "1", + layout: [], + widgets: {}, + }; + } + + // Generate new widget ID + const widgetId = nanoid(8); + + // Calculate position at the bottom + // Find the maximum y + h from existing layout items + 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, reasonable height) + const newLayoutItem = { + i: widgetId, + x: 0, + y: maxBottom, + w: 12, + h: 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + // Redirect to the dashboard + return redirect( + v3CustomDashboardPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: dashboardId } + ) + ); +}; From 1ad46db394ca9aecebad378cf90e5657a52ca194 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 1 Feb 2026 21:50:13 -0800 Subject: [PATCH 019/131] Editing and saving layouts working --- .../v3/MetricDashboardPresenter.server.ts | 4 +- .../route.tsx | 14 +- .../route.tsx | 157 ++++++++++++++++-- 3 files changed, 162 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts index d94f4ec292..704b71a822 100644 --- a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts @@ -20,7 +20,7 @@ export type MetricFilters = { tags?: string[]; }; -const LayoutItem = z.object({ +export const LayoutItem = z.object({ i: z.string(), x: z.number(), y: z.number(), @@ -28,6 +28,8 @@ const LayoutItem = z.object({ h: z.number(), }); +export type LayoutItem = z.infer; + const Widget = z.object({ title: z.string(), query: z.string(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index 0d709f6c6c..59dfe2990d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -3,6 +3,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { requireUser } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { + LayoutItem, type DashboardLayout, MetricDashboardPresenter, } from "~/presenters/v3/MetricDashboardPresenter.server"; @@ -75,10 +76,12 @@ export function MetricDashboard({ data, defaultPeriod, editable, + onLayoutChange, }: { data: DashboardLayout; defaultPeriod: string; editable: boolean; + onLayoutChange?: (layout: LayoutItem[]) => void; }) { const [layout, setLayout] = useState(data.layout); const { value } = useSearchParams(); @@ -96,6 +99,15 @@ export function MetricDashboard({ const from = value("from"); const to = value("to"); + const handleLayoutChange = useCallback( + (newLayout: readonly LayoutItem[]) => { + const mutableLayout = [...newLayout]; + setLayout(mutableLayout); + onLayoutChange?.(mutableLayout); + }, + [onLayoutChange] + ); + return (
@@ -118,7 +130,7 @@ export function MetricDashboard({ handles: ["e", "w", "s", "n", "ne", "nw", "se", "sw"], }} dragConfig={{ enabled: editable }} - onLayoutChange={(l) => setLayout([...l])} + onLayoutChange={handleLayoutChange} onResizeStart={(_layout, oldItem) => setResizingItemId(oldItem?.i ?? null)} onResizeStop={() => setResizingItemId(null)} > diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index cdef1e4287..d91f61896d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -1,13 +1,21 @@ +import { PencilSquareIcon } from "@heroicons/react/20/solid"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, redirect } from "@remix-run/node"; +import { useFetcher } from "@remix-run/react"; +import { useCallback, useState } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { prisma } from "~/db.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { requireUser } from "~/services/session.server"; +import { + LayoutItem, + MetricDashboardPresenter, +} from "~/presenters/v3/MetricDashboardPresenter.server"; +import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; -import { 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 { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route"; const ParamSchema = EnvironmentParamSchema.extend({ @@ -15,10 +23,10 @@ const ParamSchema = EnvironmentParamSchema.extend({ }); export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUser(request); + const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params); - const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response(undefined, { status: 404, @@ -26,7 +34,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); } - const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { throw new Response(undefined, { status: 404, @@ -43,17 +51,144 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson(dashboard); }; +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; + } + }), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, 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 result = SaveLayoutSchema.safeParse({ + layout: formData.get("layout"), + }); + + if (!result.success) { + throw new Response("Invalid form data: " + result.error.message, { status: 400 }); + } + + // Parse existing layout to preserve widgets + const existingLayout = JSON.parse(dashboard.layout) as Record; + + // Update layout positions while preserving widgets + const updatedLayout = { + ...existingLayout, + layout: result.data.layout, + }; + + // Save to database + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true }); +}; + export default function Page() { const { title, layout, defaultPeriod } = useTypedLoaderData(); + const fetcher = useFetcher(); + + const [isEditing, setIsEditing] = useState(false); + const [pendingLayout, setPendingLayout] = useState(null); + + const isSaving = fetcher.state === "submitting"; + + const handleLayoutChange = useCallback((newLayout: LayoutItem[]) => { + setPendingLayout(newLayout); + }, []); + + const handleEdit = () => { + setIsEditing(true); + setPendingLayout(null); + }; + + const handleCancel = () => { + setIsEditing(false); + setPendingLayout(null); + }; + + const handleSave = () => { + if (!pendingLayout) { + // No changes made, just exit edit mode + setIsEditing(false); + return; + } + + fetcher.submit({ layout: JSON.stringify(pendingLayout) }, { method: "POST" }); + + setIsEditing(false); + setPendingLayout(null); + }; return ( + + {isEditing ? ( +
+ + +
+ ) : ( + + )} +
- +
From d548acb9057d1d611c61af4efa7ef1baa82b2dc3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 1 Feb 2026 21:51:44 -0800 Subject: [PATCH 020/131] Cancel button reverts the layout --- .../route.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index d91f61896d..dc9455b5ca 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -130,6 +130,7 @@ export default function Page() { const [isEditing, setIsEditing] = useState(false); const [pendingLayout, setPendingLayout] = useState(null); + const [resetKey, setResetKey] = useState(0); const isSaving = fetcher.state === "submitting"; @@ -145,6 +146,8 @@ export default function Page() { const handleCancel = () => { setIsEditing(false); setPendingLayout(null); + // Increment key to force remount and reset layout to original + setResetKey((k) => k + 1); }; const handleSave = () => { @@ -184,6 +187,7 @@ export default function Page() {
Date: Mon, 2 Feb 2026 07:31:52 -0800 Subject: [PATCH 021/131] SideMenu section for Metrics --- apps/webapp/app/components/AlphaBadge.tsx | 29 + .../app/components/navigation/SideMenu.tsx | 647 +++++++++--------- .../components/navigation/SideMenuSection.tsx | 6 +- .../route.tsx | 6 +- apps/webapp/app/utils/pathBuilder.ts | 6 +- 5 files changed, 367 insertions(+), 327 deletions(-) diff --git a/apps/webapp/app/components/AlphaBadge.tsx b/apps/webapp/app/components/AlphaBadge.tsx index 58da1a994c..0a1c4a7fc9 100644 --- a/apps/webapp/app/components/AlphaBadge.tsx +++ b/apps/webapp/app/components/AlphaBadge.tsx @@ -30,3 +30,32 @@ export function AlphaTitle({ children }: { children: React.ReactNode }) { ); } + +export function BetaBadge({ + inline = false, + className, +}: { + inline?: boolean; + className?: string; +}) { + return ( + + Beta + + } + content="This feature is in Beta." + disableHoverableContent + /> + ); +} + +export function BetaTitle({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 55b3e5a831..5174633434 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -21,7 +21,7 @@ import { ServerStackIcon, Squares2X2Icon, TableCellsIcon, - UsersIcon + UsersIcon, } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, Link, useFetcher, useNavigation } from "@remix-run/react"; @@ -84,7 +84,7 @@ import { v3UsagePath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; -import { AlphaBadge } from "../AlphaBadge"; +import { AlphaBadge, BetaBadge } from "../AlphaBadge"; import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; @@ -96,14 +96,15 @@ import { Input } from "../primitives/Input"; import { InputGroup } from "../primitives/InputGroup"; import { Label } from "../primitives/Label"; import { Paragraph } from "../primitives/Paragraph"; -import { - Popover, - PopoverContent, - PopoverMenuItem, - PopoverTrigger -} from "../primitives/Popover"; +import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; 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 { EnvironmentSelector } from "./EnvironmentSelector"; @@ -111,8 +112,12 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { BarChart2Icon, LineChartIcon } from "lucide-react"; -type SideMenuUser = Pick & { +type SideMenuUser = Pick< + UserWithDashboardPreferences, + "email" | "admin" | "dashboardPreferences" +> & { isImpersonating: boolean; }; export type SideMenuProject = Pick< @@ -264,333 +269,342 @@ 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" && ( - - - - - -
- -
-
- - {isConnected === undefined - ? "Checking connection..." - : isConnected - ? "Your dev server is connected" - : "Your dev server is not connected"} - -
-
- -
-
- )} +
+ + {environment.type === "DEVELOPMENT" && project.engine === "V2" && ( + + + + + +
+ +
+
+ + {isConnected === undefined + ? "Checking connection..." + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"} + +
+
+ +
+
+ )} +
-
-
- - - - - - - - {(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.hasQueryAccess) && ( } + name="Batches" + icon={Squares2X2Icon} + activeIconColor="text-batches" + inactiveIconColor="text-batches" + to={v3BatchesPath(organization, project, environment)} + data-action="batches" isCollapsed={isCollapsed} /> + + + + + {(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && ( + } + isCollapsed={isCollapsed} + /> + )} + +
+ + {(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && ( + + } + > + } + isCollapsed={isCollapsed} + /> + + {customDashboards.map((dashboard) => ( + + ))} + )} -
- {(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && ( - } + initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false} + onCollapseToggle={handleManageSectionToggle} > - {customDashboards.map((dashboard) => ( + + + + + {isManagedCloud && ( - ))} - - )} - - - - - - - - {isManagedCloud && ( + )} + + + +
+
+
+ + - - - + > + + {isFreeUser && ( + + + + )} +
-
- - - - {isFreeUser && ( - - - - )} - -
-
); } @@ -937,7 +951,12 @@ function CollapsibleHeight({ function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) { return ( -
+
@@ -1052,7 +1071,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) { @@ -1065,7 +1084,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) @@ -1081,7 +1100,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(), @@ -1124,13 +1143,7 @@ function AnimatedChevron({ ); } -function CollapseToggle({ - isCollapsed, - onToggle, -}: { - isCollapsed: boolean; - onToggle: () => void; -}) { +function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) { const [isHovering, setIsHovering] = useState(false); return ( diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 904c3f0c25..3d27b99b34 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -52,7 +52,7 @@ export function SideMenuSection({ onClick={isSideMenuCollapsed ? undefined : handleToggle} style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }} > -

{title}

+

{title}

- {headerAction && ( -
{headerAction}
- )} + {headerAction &&
{headerAction}
} {/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */} - Query} /> + Query} /> diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 24ac360c89..4f1c03d8d6 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -280,7 +280,9 @@ export function v3CustomDashboardPath( environment: EnvironmentForPath, dashboard: { friendlyId: string } ) { - return `${v3EnvironmentPath(organization, project, environment)}/metrics/custom/${dashboard.friendlyId}`; + return `${v3EnvironmentPath(organization, project, environment)}/metrics/custom/${ + dashboard.friendlyId + }`; } export function v3BuiltInDashboardPath( @@ -500,7 +502,7 @@ export function v3ProjectSettingsPath( export function v3LogsPath( organization: OrgForPath, project: ProjectForPath, - environment: EnvironmentForPath, + environment: EnvironmentForPath ) { return `${v3EnvironmentPath(organization, project, environment)}/logs`; } From e1554e3c7abf53f61bcd7e70843de8655442e839 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 2 Feb 2026 07:36:13 -0800 Subject: [PATCH 022/131] Fix for side menu collapsible bg --- apps/webapp/app/components/navigation/SideMenu.tsx | 1 - apps/webapp/app/components/navigation/SideMenuSection.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 5174633434..ff3e73c985 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -464,7 +464,6 @@ export function SideMenu({ inactiveIconColor="text-purple-500" to={queryPath(organization, project, environment)} data-action="query" - badge={} isCollapsed={isCollapsed} /> {/* Header - fades out when sidebar is collapsed */}
From 181f39742c776577356509ef004feba32cdc5600 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 3 Feb 2026 16:44:53 -0800 Subject: [PATCH 023/131] Drag and resize without edit mode --- .../app/components/metrics/QueryWidget.tsx | 4 +- .../app/components/primitives/charts/Card.tsx | 17 ++- .../route.tsx | 18 ++- .../route.tsx | 106 +++++++++--------- apps/webapp/app/routes/resources.metric.tsx | 11 +- 5 files changed, 97 insertions(+), 59 deletions(-) diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index a11e8e843e..d22c23df10 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -76,6 +76,7 @@ export type QueryWidgetProps = { config: QueryWidgetConfig; accessory?: ReactNode; isResizing?: boolean; + isDraggable?: boolean; }; export function QueryWidget({ @@ -84,13 +85,14 @@ export function QueryWidget({ isLoading, error, isResizing, + isDraggable, ...props }: QueryWidgetProps) { const [isFullscreen, setIsFullscreen] = useState(false); return ( - +
{title}
{accessory} diff --git a/apps/webapp/app/components/primitives/charts/Card.tsx b/apps/webapp/app/components/primitives/charts/Card.tsx index c618b51d01..9249832b5e 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/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index 59dfe2990d..9a1d0aeb6c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -56,7 +56,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { title, layout, defaultPeriod } = useTypedLoaderData(); + const { key, title, layout, defaultPeriod } = useTypedLoaderData(); return ( @@ -65,7 +65,7 @@ export default function Page() {
- +
@@ -88,6 +88,14 @@ export function MetricDashboard({ const { width, containerRef, mounted } = useContainerWidth(); const [resizingItemId, setResizingItemId] = useState(null); + // Sync layout state when navigating to a different dashboard. + // useState only initializes once, so we need this effect to update + // the layout when the data prop changes (e.g., switching dashboards). + const dataLayoutJson = JSON.stringify(data.layout); + useEffect(() => { + setLayout(data.layout); + }, [dataLayoutJson]); + const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -127,9 +135,9 @@ export function MetricDashboard({ gridConfig={{ cols: 12, rowHeight: 30 }} resizeConfig={{ enabled: editable, - handles: ["e", "w", "s", "n", "ne", "nw", "se", "sw"], + handles: ["se"], }} - dragConfig={{ enabled: editable }} + dragConfig={{ enabled: editable, handle: ".drag-handle" }} onLayoutChange={handleLayoutChange} onResizeStart={(_layout, oldItem) => setResizingItemId(oldItem?.i ?? null)} onResizeStop={() => setResizingItemId(null)} @@ -137,6 +145,7 @@ export function MetricDashboard({ {Object.entries(data.widgets).map(([key, widget]) => (
))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index dc9455b5ca..6d4837647f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -1,12 +1,10 @@ -import { PencilSquareIcon } from "@heroicons/react/20/solid"; import { type ActionFunctionArgs, type LoaderFunctionArgs, redirect } from "@remix-run/node"; import { useFetcher } from "@remix-run/react"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; -import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { prisma } from "~/db.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; @@ -125,72 +123,78 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }; export default function Page() { - const { title, layout, defaultPeriod } = useTypedLoaderData(); + const { friendlyId, title, layout, defaultPeriod } = useTypedLoaderData(); const fetcher = useFetcher(); + const debounceTimeoutRef = useRef | null>(null); + const isInitializedRef = useRef(false); + const currentLayoutJsonRef = useRef(JSON.stringify(layout.layout)); + + // Track when the dashboard data changes (e.g., switching dashboards) + const layoutJson = JSON.stringify(layout.layout); + useEffect(() => { + // Cancel any pending save when switching dashboards + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = null; + } - const [isEditing, setIsEditing] = useState(false); - const [pendingLayout, setPendingLayout] = useState(null); - const [resetKey, setResetKey] = useState(0); - - const isSaving = fetcher.state === "submitting"; + // Update the current layout reference and mark as not yet user-modified + currentLayoutJsonRef.current = layoutJson; + isInitializedRef.current = false; - const handleLayoutChange = useCallback((newLayout: LayoutItem[]) => { - setPendingLayout(newLayout); - }, []); + // Allow saves after a short delay to skip initial mount callbacks + const initTimeout = setTimeout(() => { + isInitializedRef.current = true; + }, 100); - const handleEdit = () => { - setIsEditing(true); - setPendingLayout(null); - }; + return () => { + clearTimeout(initTimeout); + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, [layoutJson]); + + const handleLayoutChange = useCallback( + (newLayout: LayoutItem[]) => { + // Skip if not yet initialized (prevents saving during mount/navigation) + if (!isInitializedRef.current) { + return; + } - const handleCancel = () => { - setIsEditing(false); - setPendingLayout(null); - // Increment key to force remount and reset layout to original - setResetKey((k) => k + 1); - }; + const newLayoutJson = JSON.stringify(newLayout); - const handleSave = () => { - if (!pendingLayout) { - // No changes made, just exit edit mode - setIsEditing(false); - return; - } + // Skip if layout hasn't actually changed + if (newLayoutJson === currentLayoutJsonRef.current) { + return; + } - fetcher.submit({ layout: JSON.stringify(pendingLayout) }, { method: "POST" }); + // Clear existing timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } - setIsEditing(false); - setPendingLayout(null); - }; + // Debounce auto-save by 500ms + debounceTimeoutRef.current = setTimeout(() => { + currentLayoutJsonRef.current = newLayoutJson; + fetcher.submit({ layout: newLayoutJson }, { method: "POST" }); + }, 500); + }, + [fetcher] + ); return ( - - {isEditing ? ( -
- - -
- ) : ( - - )} -
diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index 5ffa3e23cf..2e6635f144 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -116,20 +116,28 @@ export const action = async ({ request }: ActionFunctionArgs) => { }; type MetricWidgetProps = { + /** Unique key for this widget - used to identify the fetcher */ + widgetKey: string; title: string; config: QueryWidgetConfig; refreshIntervalMs?: number; isResizing?: boolean; + isDraggable?: boolean; } & z.infer; export function MetricWidget({ + widgetKey, title, config, refreshIntervalMs, isResizing, + isDraggable, ...props }: MetricWidgetProps) { - const fetcher = useFetcher(); + // Use a unique key for each widget's fetcher to prevent "Expected fetch controller" errors + // when navigating between dashboards. Without a key, Remix can't properly track and clean up + // fetchers when components unmount during navigation. + const fetcher = useFetcher({ key: `metric-widget-${widgetKey}` }); const isLoading = fetcher.state !== "idle"; const submit = useCallback(async () => { @@ -160,6 +168,7 @@ export function MetricWidget({ data={data} error={fetcher.data?.success === false ? fetcher.data.error : undefined} isResizing={isResizing} + isDraggable={isDraggable} /> ); } From e33af40ecfc88b16a502603c6c0785ea36884913 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 3 Feb 2026 16:57:59 -0800 Subject: [PATCH 024/131] Delete dashboard implemented --- .../route.tsx | 158 ++++++++++++++---- 1 file changed, 130 insertions(+), 28 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 6d4837647f..1edd6cb43c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -1,11 +1,28 @@ -import { type ActionFunctionArgs, type LoaderFunctionArgs, redirect } from "@remix-run/node"; -import { useFetcher } from "@remix-run/react"; -import { useCallback, useEffect, useRef } from "react"; +import { TrashIcon } from "@heroicons/react/20/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; +import { Form, useFetcher, useNavigation } from "@remix-run/react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Button } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "~/components/primitives/Dialog"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Popover, + PopoverContent, + PopoverVerticalEllipseTrigger, +} from "~/components/primitives/Popover"; import { prisma } from "~/db.server"; +import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { @@ -13,7 +30,7 @@ import { MetricDashboardPresenter, } from "~/presenters/v3/MetricDashboardPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder"; import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route"; const ParamSchema = EnvironmentParamSchema.extend({ @@ -74,7 +91,7 @@ const SaveLayoutSchema = z.object({ export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam, organizationSlug, dashboardId } = ParamSchema.parse(params); + const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { @@ -94,41 +111,80 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } const formData = await request.formData(); - const result = SaveLayoutSchema.safeParse({ - layout: formData.get("layout"), - }); + const action = formData.get("action"); - if (!result.success) { - throw new Response("Invalid form data: " + result.error.message, { status: 400 }); - } + switch (action) { + case "delete": { + await prisma.metricsDashboard.delete({ + where: { id: dashboard.id }, + }); - // Parse existing layout to preserve widgets - const existingLayout = JSON.parse(dashboard.layout) as Record; + return redirectWithSuccessMessage( + v3BuiltInDashboardPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + "overview" + ), + request, + `Deleted "${dashboard.title}" dashboard` + ); + } + case "layout": { + const result = SaveLayoutSchema.safeParse({ + layout: formData.get("layout"), + }); - // Update layout positions while preserving widgets - const updatedLayout = { - ...existingLayout, - layout: result.data.layout, - }; + if (!result.success) { + throw new Response("Invalid form data: " + result.error.message, { status: 400 }); + } - // Save to database - await prisma.metricsDashboard.update({ - where: { id: dashboard.id }, - data: { - layout: JSON.stringify(updatedLayout), - }, - }); + // Parse existing layout to preserve widgets + const existingLayout = JSON.parse(dashboard.layout) as Record; + + // Update layout positions while preserving widgets + const updatedLayout = { + ...existingLayout, + layout: result.data.layout, + }; + + // Save to database + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); - return typedjson({ success: true }); + return typedjson({ success: true }); + } + default: { + throw new Response("Invalid action", { status: 400 }); + } + } }; export default function Page() { const { friendlyId, title, layout, defaultPeriod } = useTypedLoaderData(); const fetcher = useFetcher(); + const navigation = useNavigation(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const debounceTimeoutRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(layout.layout)); + const isDeleting = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "delete"; + + // Close dialog when navigation completes (after successful delete) + useEffect(() => { + if (navigation.state === "idle") { + setIsDeleteDialogOpen(false); + } + }, [navigation.state]); + // Track when the dashboard data changes (e.g., switching dashboards) const layoutJson = JSON.stringify(layout.layout); useEffect(() => { @@ -177,7 +233,7 @@ export default function Page() { // Debounce auto-save by 500ms debounceTimeoutRef.current = setTimeout(() => { currentLayoutJsonRef.current = newLayoutJson; - fetcher.submit({ layout: newLayoutJson }, { method: "POST" }); + fetcher.submit({ action: "layout", layout: newLayoutJson }, { method: "POST" }); }, 500); }, [fetcher] @@ -187,6 +243,52 @@ export default function Page() { + + + + + + + + + + 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. + +
+ + + + +
+ +
+
+
+
+
+
+
From 9572762398b992028191dc830ab6d28e802cb88e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 3 Feb 2026 17:11:05 -0800 Subject: [PATCH 025/131] Rename dashboard --- .../app/components/navigation/SideMenu.tsx | 10 --- .../route.tsx | 85 ++++++++++++++++++- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index ff3e73c985..19113d92ea 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1016,7 +1016,6 @@ function CreateDashboardButton({ function CreateDashboardDialog({ formAction }: { formAction: string }) { const navigation = useNavigation(); const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); const isLoading = navigation.formAction === formAction; @@ -1034,15 +1033,6 @@ function CreateDashboardDialog({ formAction }: { formAction: string }) { required /> - - - setDescription(e.target.value)} - placeholder="Dashboard description" - /> - diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 1edd6cb43c..97343b3995 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -1,4 +1,4 @@ -import { TrashIcon } from "@heroicons/react/20/solid"; +import { PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; import { Form, useFetcher, useNavigation } from "@remix-run/react"; @@ -14,6 +14,10 @@ import { DialogHeader, DialogTrigger, } from "~/components/primitives/Dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; +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 { @@ -130,6 +134,19 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { `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 }); + } case "layout": { const result = SaveLayoutSchema.safeParse({ layout: formData.get("layout"), @@ -169,6 +186,8 @@ export default function Page() { const fetcher = useFetcher(); const navigation = useNavigation(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [newTitle, setNewTitle] = useState(title); const debounceTimeoutRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(layout.layout)); @@ -178,13 +197,24 @@ export default function Page() { navigation.formMethod === "post" && navigation.formData?.get("action") === "delete"; - // Close dialog when navigation completes (after successful delete) + const isRenaming = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "rename"; + + // Close dialogs when navigation completes and sync title state useEffect(() => { if (navigation.state === "idle") { setIsDeleteDialogOpen(false); + setIsRenameDialogOpen(false); } }, [navigation.state]); + // Sync newTitle state when title changes (after successful rename) + useEffect(() => { + setNewTitle(title); + }, [title]); + // Track when the dashboard data changes (e.g., switching dashboards) const layoutJson = JSON.stringify(layout.layout); useEffect(() => { @@ -242,7 +272,56 @@ export default function Page() { return ( - + + + {title} + + + + + + Rename dashboard +
+ + + + setNewTitle(e.target.value)} + placeholder="Dashboard title" + required + autoFocus + /> + + + {isRenaming ? "Saving…" : "Save"} + + } + cancelButton={ + + + + } + /> + +
+ + } + /> From 9835df97315c1d5fff0959f8f00ac209ddd9a238 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 3 Feb 2026 21:09:33 -0800 Subject: [PATCH 026/131] Separate components for rename/delete --- .../route.tsx | 252 ++++++++++-------- 1 file changed, 135 insertions(+), 117 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 97343b3995..12394ebcac 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -1,4 +1,4 @@ -import { PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { PencilIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node"; import { Form, useFetcher, useNavigation } from "@remix-run/react"; @@ -36,6 +36,7 @@ import { import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder"; import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route"; +import { IconEdit } from "@tabler/icons-react"; const ParamSchema = EnvironmentParamSchema.extend({ dashboardId: z.string(), @@ -184,37 +185,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { export default function Page() { const { friendlyId, title, layout, defaultPeriod } = useTypedLoaderData(); const fetcher = useFetcher(); - const navigation = useNavigation(); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const [newTitle, setNewTitle] = useState(title); const debounceTimeoutRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(layout.layout)); - const isDeleting = - navigation.state !== "idle" && - navigation.formMethod === "post" && - navigation.formData?.get("action") === "delete"; - - const isRenaming = - navigation.state !== "idle" && - navigation.formMethod === "post" && - navigation.formData?.get("action") === "rename"; - - // Close dialogs when navigation completes and sync title state - useEffect(() => { - if (navigation.state === "idle") { - setIsDeleteDialogOpen(false); - setIsRenameDialogOpen(false); - } - }, [navigation.state]); - - // Sync newTitle state when title changes (after successful rename) - useEffect(() => { - setNewTitle(title); - }, [title]); - // Track when the dashboard data changes (e.g., switching dashboards) const layoutJson = JSON.stringify(layout.layout); useEffect(() => { @@ -272,99 +246,12 @@ export default function Page() { return ( - - - {title} - - - - - - Rename dashboard -
- - - - setNewTitle(e.target.value)} - placeholder="Dashboard title" - required - autoFocus - /> - - - {isRenaming ? "Saving…" : "Save"} - - } - cancelButton={ - - - - } - /> - -
- - } - /> + } /> - - - - - - 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. - -
- - - - -
- -
-
-
-
+
@@ -383,3 +270,134 @@ export default function Page() {
); } + +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 ( + + + {title} + + + + + + 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. + +
+ + + + +
+ +
+
+
+
+ ); +} From b6210abdc07f908ae13427ca3dbc4cf2c22b2d09 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 3 Feb 2026 21:50:04 -0800 Subject: [PATCH 027/131] Metric add/edit chart --- .../app/components/metrics/QueryWidget.tsx | 92 +- .../app/components/query/QueryEditor.tsx | 996 ++++++++++++++++++ .../v3/MetricDashboardPresenter.server.ts | 6 +- .../route.tsx | 10 + .../route.tsx | 197 +++- .../route.tsx | 868 +-------------- apps/webapp/app/routes/resources.metric.tsx | 35 +- ...ram.dashboards.$dashboardId.add-widget.tsx | 26 +- ....dashboards.$dashboardId.update-widget.tsx | 121 +++ 9 files changed, 1430 insertions(+), 921 deletions(-) create mode 100644 apps/webapp/app/components/query/QueryEditor.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index d22c23df10..1d4c69c83a 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -7,11 +7,12 @@ import { TSQLResultsTable } from "../code/TSQLResultsTable"; import { QueryResultsChart } from "../code/QueryResultsChart"; import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog"; import { Button } from "../primitives/Buttons"; -import { ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; +import { ArrowsPointingOutIcon, PencilSquareIcon } from "@heroicons/react/20/solid"; import { LoadingBarDivider } from "../primitives/LoadingBarDivider"; import { Callout } from "../primitives/Callout"; import { ChartBarIcon } from "@heroicons/react/24/solid"; import { cn } from "~/utils/cn"; +import { SimpleTooltip } from "../primitives/Tooltip"; const ChartType = z.union([z.literal("bar"), z.literal("line")]); export type ChartType = z.infer; @@ -77,6 +78,8 @@ export type QueryWidgetProps = { accessory?: ReactNode; isResizing?: boolean; isDraggable?: boolean; + /** Callback when edit button is clicked. When provided, shows edit button on hover. */ + onEdit?: () => void; }; export function QueryWidget({ @@ -86,47 +89,64 @@ export function QueryWidget({ error, isResizing, isDraggable, + onEdit, ...props }: QueryWidgetProps) { const [isFullscreen, setIsFullscreen] = useState(false); + const [isHovered, setIsHovered] = useState(false); return ( - - -
{title}
- - {accessory} -
- ) : error ? ( -
- {error} -
- ) : ( - - )} - -
+ ) : error ? ( +
+ {error} +
+ ) : ( + + )} + + +
); } diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx new file mode 100644 index 0000000000..ea17588e74 --- /dev/null +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -0,0 +1,996 @@ +import { + ArrowDownTrayIcon, + BookmarkIcon, + ClipboardIcon, + XMarkIcon, +} from "@heroicons/react/20/solid"; +import type { OutputColumnMetadata } from "@internal/clickhouse"; +import { useFetcher } from "@remix-run/react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, + type ReactNode, +} from "react"; +import { flushSync } from "react-dom"; +import { useTypedFetcher } from "remix-typedjson"; +import simplur from "simplur"; +import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { BetaTitle } from "~/components/AlphaBadge"; +import { ChartConfigPanel, defaultChartConfig } from "~/components/code/ChartConfigPanel"; +import { autoFormatSQL, TSQLEditor } from "~/components/code/TSQLEditor"; +import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { + ClientTabs, + ClientTabsContent, + ClientTabsList, + ClientTabsTrigger, +} from "~/components/primitives/ClientTabs"; +import { Header3 } from "~/components/primitives/Headers"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Popover, + PopoverArrowTrigger, + PopoverContent, + PopoverMenuItem, +} from "~/components/primitives/Popover"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { Spinner } from "~/components/primitives/Spinner"; +import { Switch } from "~/components/primitives/Switch"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { TimeFilter } from "~/components/runs/v3/SharedFilters"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server"; +import type { action as titleAction } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title"; +import type { QueryScope } from "~/services/queryService.server"; +import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport"; +import { organizationBillingPath } from "~/utils/pathBuilder"; +import { querySchemas } from "~/v3/querySchemas"; +import { QueryHelpSidebar } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHelpSidebar"; +import { QueryHistoryPopover } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHistoryPopover"; +import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types"; +import { + ChartConfiguration, + QueryWidget, + type QueryWidgetConfig, +} from "~/components/metrics/QueryWidget"; +import { SaveToDashboardDialog } from "~/components/metrics/SaveToDashboardDialog"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; + +/** Convert a Date or ISO string to ISO string format */ +function toISOString(value: Date | string): string { + if (typeof value === "string") { + return value; + } + return value.toISOString(); +} + +const scopeOptions = [ + { value: "environment", label: "Environment" }, + { value: "project", label: "Project" }, + { value: "organization", label: "Organization" }, +] as const; + +// Type for the query action response +type QueryActionResponse = { + error: string | null; + rows: Record[] | null; + columns: OutputColumnMetadata[] | null; + stats: { elapsed_ns: string } | null; + hiddenColumns: string[] | null; + reachedMaxRows: boolean | null; + explainOutput: string | null; + generatedSql: string | null; + queryId?: string | null; + periodClipped: number | null; + maxQueryPeriod?: number; +}; + +export type QueryEditorMode = + | { type: "standalone" } + | { type: "dashboard-add"; dashboardId: string; dashboardName: string } + | { + type: "dashboard-edit"; + dashboardId: string; + dashboardName: string; + widgetId: string; + widgetName: string; + }; + +export type QueryEditorProps = { + // Default values - used to initialize state + defaultQuery: string; + defaultScope: QueryScope; + defaultPeriod: string; + defaultTimeFilter?: { period?: string; from?: string; to?: string }; + defaultResultsView?: "table" | "graph"; + defaultChartConfig?: ChartConfiguration; + + // Other required data + history: QueryHistoryItem[]; + isAdmin: boolean; + maxRows: number; + + // The URL to post query execution requests to + queryActionUrl: string; + + // Mode determines NavBar and save behavior + mode: QueryEditorMode; + + // Max period days (from plan) + maxPeriodDays?: number; + + // Callbacks + onSave?: (data: { title: string; query: string; config: QueryWidgetConfig }) => void; + onClose?: () => void; +}; + +/** Handle for imperatively setting the query from outside */ +interface QueryEditorFormHandle { + setQuery: (query: string) => void; + setScope: (scope: QueryScope) => void; + getQuery: () => string; + setTimeFilter: (filter: { period?: string; from?: string; to?: string }) => void; +} + +/** Self-contained query editor with form - isolates query state from parent */ +const QueryEditorForm = forwardRef< + QueryEditorFormHandle, + { + defaultPeriod: string; + defaultQuery: string; + defaultScope: QueryScope; + defaultTimeFilter?: { period?: string; from?: string; to?: string }; + history: QueryHistoryItem[]; + fetcher: ReturnType>; + isAdmin: boolean; + queryActionUrl: string; + maxPeriodDays?: number; + onQuerySubmit?: () => void; + onHistorySelected?: (item: QueryHistoryItem) => void; + } +>(function QueryEditorForm( + { + defaultPeriod, + defaultQuery, + defaultScope, + defaultTimeFilter, + history, + fetcher, + isAdmin, + queryActionUrl, + maxPeriodDays, + onQuerySubmit, + onHistorySelected, + }, + ref +) { + const isLoading = fetcher.state === "submitting" || fetcher.state === "loading"; + const [query, setQuery] = useState(defaultQuery); + const [scope, setScope] = useState(defaultScope); + const formRef = useRef(null); + const prevFetcherState = useRef(fetcher.state); + + // Notify parent when query is submitted (for title generation) + useEffect(() => { + if (prevFetcherState.current !== "submitting" && fetcher.state === "submitting") { + onQuerySubmit?.(); + } + prevFetcherState.current = fetcher.state; + }, [fetcher.state, onQuerySubmit]); + + // Get time filter values - initialize from props (which may come from history) + const [period, setPeriod] = useState(defaultTimeFilter?.period); + const [from, setFrom] = useState(defaultTimeFilter?.from); + const [to, setTo] = useState(defaultTimeFilter?.to); + + // Check if the query contains triggered_at in a WHERE clause + // This disables the time filter UI since the user is filtering in their query + const queryHasTriggeredAt = /\bWHERE\b[\s\S]*\btriggered_at\b/i.test(query); + + // Expose methods to parent for external query setting (history, AI, examples) + useImperativeHandle( + ref, + () => ({ + setQuery, + setScope, + getQuery: () => query, + setTimeFilter: (filter: { period?: string; from?: string; to?: string }) => { + setPeriod(filter.period); + setFrom(filter.from); + setTo(filter.to); + }, + }), + [query] + ); + + const handleHistorySelected = useCallback( + (item: QueryHistoryItem) => { + setQuery(item.query); + setScope(item.scope); + // Apply time filter from history item + // Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization + setPeriod(item.filterPeriod ?? undefined); + setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined); + setTo(item.filterTo ? toISOString(item.filterTo) : undefined); + // Notify parent about history selection (for title) + onHistorySelected?.(item); + }, + [onHistorySelected] + ); + + return ( +
+ + + + + {/* Pass time filter values to action */} + + + + +
+ {isAdmin && ( + + )} + + {queryHasTriggeredAt ? ( + + Set in query + + } + content="Your query includes a WHERE clause with triggered_at so this filter is disabled." + /> + ) : ( + { + flushSync(() => { + setPeriod(values.period); + setFrom(values.from); + setTo(values.to); + }); + if (formRef.current) { + fetcher.submit(formRef.current); + } + }} + maxPeriodDays={maxPeriodDays} + /> + )} + +
+
+
+ ); +}); + +export function QueryEditor({ + defaultQuery, + defaultScope, + defaultPeriod, + defaultTimeFilter, + defaultResultsView = "table", + defaultChartConfig: initialChartConfig, + history, + isAdmin, + maxRows, + queryActionUrl, + mode, + maxPeriodDays, + onSave, + onClose, +}: QueryEditorProps) { + const fetcher = useTypedFetcher(); + const results = fetcher.data; + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const editorRef = useRef(null); + const [prettyFormatting, setPrettyFormatting] = useState(true); + const [resultsView, setResultsView] = useState<"table" | "graph">(defaultResultsView); + const [chartConfig, setChartConfig] = useState( + initialChartConfig ?? defaultChartConfig + ); + const [sidebarTab, setSidebarTab] = useState("ai"); + const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null); + + // Save to dashboard dialog state (only for standalone mode) + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + + // Title generation state + const titleFetcher = useFetcher(); + const isTitleLoading = titleFetcher.state !== "idle"; + const generatedTitle = titleFetcher.data?.title; + const [historyTitle, setHistoryTitle] = useState( + history.length > 0 ? history[0].title ?? null : null + ); + + // For edit mode, use the widget name as initial title + const initialTitle = mode.type === "dashboard-edit" ? mode.widgetName : null; + const [editModeTitle, setEditModeTitle] = useState(initialTitle); + + // Effective title: edit mode title > history title > generated title + const queryTitle = + mode.type === "dashboard-edit" + ? editModeTitle ?? historyTitle ?? generatedTitle ?? null + : historyTitle ?? generatedTitle ?? null; + + // Track whether we should generate a title for the current results + const [shouldGenerateTitle, setShouldGenerateTitle] = useState(false); + + // Trigger title generation when query succeeds (only for new queries, not history) + useEffect(() => { + if ( + results?.rows && + !results.error && + results.queryId && + shouldGenerateTitle && + !historyTitle && + titleFetcher.state === "idle" + ) { + const currentQuery = editorRef.current?.getQuery(); + if (currentQuery) { + titleFetcher.submit( + { query: currentQuery, queryId: results.queryId }, + { + method: "POST", + action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-title`, + encType: "application/json", + } + ); + setShouldGenerateTitle(false); + } + } + }, [ + results, + shouldGenerateTitle, + historyTitle, + titleFetcher, + organization.slug, + project.slug, + environment.slug, + ]); + + const handleTryFixError = useCallback((errorMessage: string) => { + setSidebarTab("ai"); + setAiFixRequest((prev) => ({ + prompt: `Fix this query error: ${errorMessage}`, + key: (prev?.key ?? 0) + 1, + })); + }, []); + + // Handle time filter changes from AI + const handleTimeFilterChange = useCallback((filter: AITimeFilter) => { + editorRef.current?.setTimeFilter({ + period: filter.period, + from: filter.from, + to: filter.to, + }); + }, []); + + const isLoading = fetcher.state === "submitting" || fetcher.state === "loading"; + + // Create a stable key from columns to detect schema changes + const columnsKey = results?.columns + ? results.columns.map((c) => `${c.name}:${c.type}`).join(",") + : ""; + + // Reset chart config only when column schema actually changes + // This allows re-running queries with different WHERE clauses without losing config + useEffect(() => { + if (columnsKey && !initialChartConfig) { + setChartConfig(defaultChartConfig); + } + }, [columnsKey, initialChartConfig]); + + const handleChartConfigChange = useCallback((config: ChartConfiguration) => { + setChartConfig(config); + }, []); + + // Handle query submission - prepare for title generation + const handleQuerySubmit = useCallback(() => { + setHistoryTitle(null); // Clear history title when running a new query + setEditModeTitle(null); // Clear edit mode title when running a new query + setShouldGenerateTitle(true); // Enable title generation for new results + }, []); + + // Handle history selection - use existing title if available + const handleHistorySelected = useCallback((item: QueryHistoryItem) => { + setHistoryTitle(item.title ?? null); + setEditModeTitle(null); + setShouldGenerateTitle(false); // Don't generate title for history items + }, []); + + // Handle save for dashboard modes + const handleSave = useCallback(() => { + if (!onSave) return; + + const currentQuery = editorRef.current?.getQuery() ?? ""; + const config: QueryWidgetConfig = + resultsView === "table" + ? { type: "table", prettyFormatting, sorting: [] } + : { type: "chart", ...chartConfig }; + + onSave({ + title: queryTitle ?? "Untitled Query", + query: currentQuery, + config, + }); + }, [onSave, resultsView, prettyFormatting, chartConfig, queryTitle]); + + // Determine if save button should be enabled + const canSave = results?.rows && results.rows.length > 0 && !results.error; + + // Render NavBar based on mode + const renderNavBar = () => { + switch (mode.type) { + case "standalone": + return ( + + Query} /> + + ); + case "dashboard-add": + return ( + + + + + + + + ); + case "dashboard-edit": + return ( + + + + + + + + ); + } + }; + + return ( + + {renderNavBar()} + + + + + {/* Query editor - isolated component to prevent re-renders */} + + + + + {/* Results */} + + setResultsView(v as "table" | "graph")} + className="grid h-full max-h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden" + > + + + Table + + + Graph + + {results?.rows ? ( +
+
+ {results.reachedMaxRows ? ( + + ) : ( + + {results.rows.length > 0 + ? `${results.rows.length.toLocaleString()} Results` + : "Results"} + + )} + {results?.stats && ( + + {formatDurationNanoseconds(parseInt(results.stats.elapsed_ns, 10))} + + )} +
+
+ {results?.rows && results?.columns && results.rows.length > 0 && ( + + )} + {resultsView === "table" && ( + + )} +
+
+ ) : null} +
+ + {isLoading ? ( +
+ + Executing query... +
+ ) : results?.error ? ( +
+
+                          {results.error}
+                        
+ +
+ ) : results?.explainOutput ? ( +
+ {results.generatedSql && ( +
+ Generated ClickHouse SQL +
+
+                                {results.generatedSql}
+                              
+
+
+ )} +
+ Query Execution Plan +
+
+                              {results.explainOutput}
+                            
+
+
+
+ ) : results?.rows && results?.columns ? ( +
+ +
+ + } + data={{ + rows: results.rows, + columns: results.columns, + }} + config={{ + type: "table", + prettyFormatting, + sorting: [], + }} + accessory={ + mode.type === "standalone" ? ( + setIsSaveDialogOpen(true)} + /> + } + content="Save to dashboard" + /> + ) : undefined + } + /> +
+
+ ) : ( + + Run a query to see results here. + + )} +
+ 0 && + hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped) + ? "grid-rows-[auto_1fr]" + : "grid-rows-[1fr]" + }`} + > + {results?.rows && results?.columns && results.rows.length > 0 ? ( + <> + + setIsSaveDialogOpen(true) : undefined + } + /> + + ) : ( + + Run a query to visualize results. + + )} + +
+
+
+
+ + + { + editorRef.current?.setQuery(exampleQuery); + editorRef.current?.setScope(exampleScope); + }} + onQueryGenerated={(query) => { + const formatted = autoFormatSQL(query); + editorRef.current?.setQuery(formatted); + }} + onTimeFilterChange={handleTimeFilterChange} + getCurrentQuery={() => editorRef.current?.getQuery() ?? ""} + activeTab={sidebarTab} + onTabChange={setSidebarTab} + aiFixRequest={aiFixRequest} + /> + +
+
+ {mode.type === "standalone" && ( + + )} +
+ ); +} + +function QueryTitle({ isTitleLoading, title }: { isTitleLoading: boolean; title: string | null }) { + if (isTitleLoading) + return ( + + Generating title... + + ); + + return title ?? "Results"; +} + +function ExportResultsButton({ + rows, + columns, +}: { + rows: Record[]; + columns: OutputColumnMetadata[]; +}) { + const [isOpen, setIsOpen] = useState(false); + + const handleCopyCSV = () => { + const csv = rowsToCSV(rows, columns); + navigator.clipboard.writeText(csv); + setIsOpen(false); + }; + + const handleExportCSV = () => { + const csv = rowsToCSV(rows, columns); + const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, ""); + downloadFile(csv, `query-results-${timestamp}.csv`, "text/csv"); + setIsOpen(false); + }; + + const handleCopyJSON = () => { + const json = rowsToJSON(rows); + navigator.clipboard.writeText(json); + setIsOpen(false); + }; + + const handleExportJSON = () => { + const json = rowsToJSON(rows); + const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, ""); + downloadFile(json, `query-results-${timestamp}.json`, "application/json"); + setIsOpen(false); + }; + + return ( + + + Export + + +
+ + + + +
+
+
+ ); +} + +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 ( + <> + Env: + + ); + default: + return scope; + } +} + +function QueryResultsCallouts({ + hiddenColumns, + periodClipped, + organizationSlug, +}: { + hiddenColumns: string[] | null | undefined; + periodClipped: number | null; + organizationSlug: string; +}) { + const hasCallouts = (hiddenColumns && hiddenColumns.length > 0) || periodClipped; + + if (!hasCallouts) { + return null; + } + + return ( +
+ {hiddenColumns && hiddenColumns.length > 0 && ( + + SELECT * doesn't return all columns because it's slow. The following columns + are not shown: {hiddenColumns.join(", ")}. + Specify them explicitly to include them. + + )} + {periodClipped && ( + + Upgrade + + } + className="items-center" + > + {simplur`Results are limited to the last ${periodClipped} day[|s] based on your plan.`} + + )} +
+ ); +} + +function hasQueryResultsCallouts( + hiddenColumns: string[] | null | undefined, + periodClipped: number | null +): boolean { + return (hiddenColumns && hiddenColumns.length > 0) || !!periodClipped; +} + +function ResultsChart({ + rows, + columns, + chartConfig, + onChartConfigChange, + queryTitle, + isTitleLoading, + onSaveClick, +}: { + rows: Record[]; + columns: OutputColumnMetadata[]; + chartConfig: ChartConfiguration; + onChartConfigChange: (config: ChartConfiguration) => void; + queryTitle: string | null; + isTitleLoading: boolean; + onSaveClick?: () => void; +}) { + return ( + <> + + +
+ } + data={{ + rows, + columns, + }} + config={{ + type: "chart", + ...chartConfig, + }} + accessory={ + onSaveClick ? ( + + } + content="Save to dashboard" + /> + ) : undefined + } + /> +
+
+ + + + +
+ + ); +} diff --git a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts index 704b71a822..bb2b9d5e5e 100644 --- a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts @@ -30,13 +30,15 @@ export const LayoutItem = z.object({ export type LayoutItem = z.infer; -const Widget = z.object({ +export const Widget = z.object({ title: z.string(), query: z.string(), display: QueryWidgetConfig, }); -const DashboardLayout = z.discriminatedUnion("version", [ +export type Widget = z.infer; + +export const DashboardLayout = z.discriminatedUnion("version", [ z.object({ version: z.literal("1"), layout: z.array(LayoutItem), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index 9a1d0aeb6c..020c44b64b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -72,16 +72,25 @@ export default function Page() { ); } +// Widget data type for edit callbacks +type WidgetData = { + title: string; + query: string; + display: import("~/components/metrics/QueryWidget").QueryWidgetConfig; +}; + export function MetricDashboard({ data, defaultPeriod, editable, onLayoutChange, + onEditWidget, }: { data: DashboardLayout; defaultPeriod: string; editable: boolean; onLayoutChange?: (layout: LayoutItem[]) => void; + onEditWidget?: (widgetId: string, widget: WidgetData) => void; }) { const [layout, setLayout] = useState(data.layout); const { value } = useSearchParams(); @@ -159,6 +168,7 @@ export function MetricDashboard({ refreshIntervalMs={60_000} isResizing={resizingItemId === key} isDraggable={editable} + onEdit={onEditWidget ? () => onEditWidget(key, widget) : undefined} />
))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 12394ebcac..0e0ccfd184 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -1,4 +1,4 @@ -import { PencilIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { 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, useFetcher, useNavigation } from "@remix-run/react"; @@ -26,6 +26,7 @@ import { PopoverVerticalEllipseTrigger, } from "~/components/primitives/Popover"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; @@ -33,20 +34,28 @@ import { LayoutItem, MetricDashboardPresenter, } from "~/presenters/v3/MetricDashboardPresenter.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder"; +import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server"; +import { requireUser, requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, queryPath, v3BuiltInDashboardPath } from "~/utils/pathBuilder"; import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route"; import { IconEdit } from "@tabler/icons-react"; +import { QueryEditor } from "~/components/query/QueryEditor"; +import type { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { defaultChartConfig } from "~/components/code/ChartConfigPanel"; const ParamSchema = EnvironmentParamSchema.extend({ dashboardId: z.string(), }); export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); + const user = await requireUser(request); const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.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, @@ -54,7 +63,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, @@ -62,13 +71,29 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); } - const presenter = new MetricDashboardPresenter(); - const dashboard = await presenter.customDashboard({ + const dashboardPresenter = new MetricDashboardPresenter(); + const dashboard = await dashboardPresenter.customDashboard({ friendlyId: dashboardId, organizationId: project.organizationId, }); - return typedjson(dashboard); + // Load query-related data for the editor + const queryPresenter = new QueryPresenter(); + const { defaultQuery, history } = await queryPresenter.call({ + organizationId: project.organizationId, + }); + + // Admins and impersonating users can use EXPLAIN + const isAdmin = user.admin || user.isImpersonating; + + return typedjson({ + ...dashboard, + // Query editor data + queryDefaultQuery: defaultQuery, + queryHistory: history, + isAdmin, + maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS, + }); }; const SaveLayoutSchema = z.object({ @@ -182,13 +207,54 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } }; +// Widget data type for edit mode +type WidgetData = { + title: string; + query: string; + display: QueryWidgetConfig; +}; + +// Editor mode state type +type EditorMode = + | null + | { type: "add" } + | { type: "edit"; widgetId: string; widget: WidgetData }; + export default function Page() { - const { friendlyId, title, layout, defaultPeriod } = useTypedLoaderData(); + const { + friendlyId, + title, + layout, + defaultPeriod, + queryDefaultQuery, + queryHistory, + isAdmin, + maxRows, + } = useTypedLoaderData(); + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const plan = useCurrentPlan(); + const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; + const fetcher = useFetcher(); + const addWidgetFetcher = useFetcher(); + const updateWidgetFetcher = useFetcher(); const debounceTimeoutRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(layout.layout)); + // Editor mode state + const [editorMode, setEditorMode] = useState(null); + + // Build the query action URL + const queryActionUrl = queryPath( + { slug: organization.slug }, + { slug: project.slug }, + { slug: environment.slug } + ); + // Track when the dashboard data changes (e.g., switching dashboards) const layoutJson = JSON.stringify(layout.layout); useEffect(() => { @@ -215,6 +281,16 @@ export default function Page() { }; }, [layoutJson]); + // Close editor when add/update operation completes + useEffect(() => { + if ( + (addWidgetFetcher.state === "idle" && addWidgetFetcher.data) || + (updateWidgetFetcher.state === "idle" && updateWidgetFetcher.data) + ) { + setEditorMode(null); + } + }, [addWidgetFetcher.state, addWidgetFetcher.data, updateWidgetFetcher.state, updateWidgetFetcher.data]); + const handleLayoutChange = useCallback( (newLayout: LayoutItem[]) => { // Skip if not yet initialized (prevents saving during mount/navigation) @@ -243,11 +319,111 @@ export default function Page() { [fetcher] ); + const handleEditWidget = useCallback((widgetId: string, widget: WidgetData) => { + setEditorMode({ type: "edit", widgetId, widget }); + }, []); + + const handleSave = useCallback( + (data: { title: string; query: string; config: QueryWidgetConfig }) => { + if (editorMode?.type === "add") { + // Submit to add-widget action + addWidgetFetcher.submit( + { + title: data.title, + query: data.query, + config: JSON.stringify(data.config), + }, + { + method: "POST", + action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/add-widget`, + } + ); + } else if (editorMode?.type === "edit") { + // Submit to update-widget action + updateWidgetFetcher.submit( + { + widgetId: editorMode.widgetId, + title: data.title, + query: data.query, + config: JSON.stringify(data.config), + }, + { + method: "POST", + action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/update-widget`, + } + ); + } + }, + [editorMode, addWidgetFetcher, updateWidgetFetcher, organization.slug, project.slug, environment.slug, friendlyId] + ); + + const handleCloseEditor = useCallback(() => { + setEditorMode(null); + }, []); + + // When in editor mode, render the QueryEditor + if (editorMode) { + const mode = + editorMode.type === "add" + ? { type: "dashboard-add" as const, dashboardId: friendlyId, dashboardName: title } + : { + type: "dashboard-edit" as const, + dashboardId: friendlyId, + dashboardName: title, + widgetId: editorMode.widgetId, + widgetName: editorMode.widget.title, + }; + + // For edit mode, use the widget's existing values as defaults + const editorDefaultQuery = + editorMode.type === "edit" ? editorMode.widget.query : queryDefaultQuery; + const editorDefaultChartConfig = + editorMode.type === "edit" && editorMode.widget.display.type === "chart" + ? { + chartType: editorMode.widget.display.chartType, + xAxisColumn: editorMode.widget.display.xAxisColumn, + yAxisColumns: editorMode.widget.display.yAxisColumns, + groupByColumn: editorMode.widget.display.groupByColumn, + stacked: editorMode.widget.display.stacked, + sortByColumn: editorMode.widget.display.sortByColumn, + sortDirection: editorMode.widget.display.sortDirection, + aggregation: editorMode.widget.display.aggregation, + } + : defaultChartConfig; + const editorDefaultResultsView = + editorMode.type === "edit" ? editorMode.widget.display.type : "table"; + + return ( + + ); + } + return ( } /> + @@ -264,6 +440,7 @@ export default function Page() { defaultPeriod={defaultPeriod} editable={true} onLayoutChange={handleLayoutChange} + onEditWidget={handleEditWidget} />
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx index 11d1ec0f42..d518f75e4f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -1,84 +1,19 @@ -import { - ArrowDownTrayIcon, - ArrowsPointingOutIcon, - ArrowTrendingUpIcon, - BookmarkIcon, - ClipboardIcon, - TableCellsIcon, -} from "@heroicons/react/20/solid"; -import type { OutputColumnMetadata } from "@internal/clickhouse"; -import { type WhereClauseCondition } from "@internal/tsql"; -import { useFetcher } from "@remix-run/react"; -import { - redirect, - type ActionFunctionArgs, - type LoaderFunctionArgs, -} from "@remix-run/server-runtime"; -import parse from "parse-duration"; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; -import { flushSync } from "react-dom"; -import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; -import simplur from "simplur"; +import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; -import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; -import { BetaTitle } from "~/components/AlphaBadge"; -import { ChartConfigPanel, defaultChartConfig } from "~/components/code/ChartConfigPanel"; -import { autoFormatSQL, TSQLEditor } from "~/components/code/TSQLEditor"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; -import { Card } from "~/components/primitives/charts/Card"; -import { - ClientTabs, - ClientTabsContent, - ClientTabsList, - ClientTabsTrigger, -} from "~/components/primitives/ClientTabs"; -import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; -import { Header3 } from "~/components/primitives/Headers"; -import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { - Popover, - PopoverArrowTrigger, - PopoverContent, - PopoverMenuItem, -} from "~/components/primitives/Popover"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "~/components/primitives/Resizable"; -import { Select, SelectItem } from "~/components/primitives/Select"; -import { Spinner } from "~/components/primitives/Spinner"; -import { Switch } from "~/components/primitives/Switch"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { TimeFilter, timeFilters } from "~/components/runs/v3/SharedFilters"; -import { prisma } from "~/db.server"; +import { QueryEditor } from "~/components/query/QueryEditor"; import { env } from "~/env.server"; -import { useEnvironment } from "~/hooks/useEnvironment"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { QueryPresenter, type QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server"; -import type { action as titleAction } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title"; -import { getLimit } from "~/services/platform.v3.server"; -import { executeQuery, getDefaultPeriod, type QueryScope } from "~/services/queryService.server"; +import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server"; +import { executeQuery, getDefaultPeriod } from "~/services/queryService.server"; import { requireUser } from "~/services/session.server"; -import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport"; -import { EnvironmentParamSchema, organizationBillingPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, queryPath } from "~/utils/pathBuilder"; import { canAccessQuery } from "~/v3/canAccessQuery.server"; -import { querySchemas } from "~/v3/querySchemas"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { QueryHelpSidebar } from "./QueryHelpSidebar"; -import { QueryHistoryPopover } from "./QueryHistoryPopover"; -import type { AITimeFilter } from "./types"; -import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; -import { ChartConfiguration, QueryWidget } from "~/components/metrics/QueryWidget"; -import { SaveToDashboardDialog } from "~/components/metrics/SaveToDashboardDialog"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useEnvironment } from "~/hooks/useEnvironment"; /** Convert a Date or ISO string to ISO string format */ function toISOString(value: Date | string): string { @@ -88,12 +23,6 @@ function toISOString(value: Date | string): string { return value.toISOString(); } -const scopeOptions = [ - { value: "environment", label: "Environment" }, - { value: "project", label: "Project" }, - { value: "organization", label: "Organization" }, -] as const; - export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); @@ -315,786 +244,47 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } }; -/** Handle for imperatively setting the query from outside */ -interface QueryEditorFormHandle { - setQuery: (query: string) => void; - setScope: (scope: QueryScope) => void; - getQuery: () => string; - setTimeFilter: (filter: { period?: string; from?: string; to?: string }) => void; -} - -/** Self-contained query editor with form - isolates query state from parent */ -const QueryEditorForm = forwardRef< - QueryEditorFormHandle, - { - defaultPeriod: string; - defaultQuery: string; - defaultScope: QueryScope; - defaultTimeFilter?: { period?: string; from?: string; to?: string }; - history: QueryHistoryItem[]; - fetcher: ReturnType>; - isAdmin: boolean; - onQuerySubmit?: () => void; - onHistorySelected?: (item: QueryHistoryItem) => void; - } ->(function QueryEditorForm( - { - defaultPeriod, - defaultQuery, - defaultScope, - defaultTimeFilter, - history, - fetcher, - isAdmin, - onQuerySubmit, - onHistorySelected, - }, - ref -) { - const isLoading = fetcher.state === "submitting" || fetcher.state === "loading"; - const [query, setQuery] = useState(defaultQuery); - const [scope, setScope] = useState(defaultScope); - const formRef = useRef(null); - const prevFetcherState = useRef(fetcher.state); - const plan = useCurrentPlan(); - const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; - - // Notify parent when query is submitted (for title generation) - useEffect(() => { - if (prevFetcherState.current !== "submitting" && fetcher.state === "submitting") { - onQuerySubmit?.(); - } - prevFetcherState.current = fetcher.state; - }, [fetcher.state, onQuerySubmit]); - - // Get time filter values - initialize from props (which may come from history) - const [period, setPeriod] = useState(defaultTimeFilter?.period); - const [from, setFrom] = useState(defaultTimeFilter?.from); - const [to, setTo] = useState(defaultTimeFilter?.to); - - // Check if the query contains triggered_at in a WHERE clause - // This disables the time filter UI since the user is filtering in their query - const queryHasTriggeredAt = /\bWHERE\b[\s\S]*\btriggered_at\b/i.test(query); - - // Expose methods to parent for external query setting (history, AI, examples) - useImperativeHandle( - ref, - () => ({ - setQuery, - setScope, - getQuery: () => query, - setTimeFilter: (filter: { period?: string; from?: string; to?: string }) => { - setPeriod(filter.period); - setFrom(filter.from); - setTo(filter.to); - }, - }), - [query] - ); - - const handleHistorySelected = useCallback( - (item: QueryHistoryItem) => { - setQuery(item.query); - setScope(item.scope); - // Apply time filter from history item - // Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization - setPeriod(item.filterPeriod ?? undefined); - setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined); - setTo(item.filterTo ? toISOString(item.filterTo) : undefined); - // Notify parent about history selection (for title) - onHistorySelected?.(item); - }, - [onHistorySelected] - ); - - return ( -
- - - - - {/* Pass time filter values to action */} - - - - -
- {isAdmin && ( - - )} - - {queryHasTriggeredAt ? ( - - Set in query - - } - content="Your query includes a WHERE clause with triggered_at so this filter is disabled." - /> - ) : ( - { - flushSync(() => { - setPeriod(values.period); - setFrom(values.from); - setTo(values.to); - }); - if (formRef.current) { - fetcher.submit(formRef.current); - } - }} - maxPeriodDays={maxPeriodDays} - /> - )} - -
-
-
- ); -}); - export default function Page() { const { defaultPeriod, defaultQuery, history, isAdmin, maxRows } = useTypedLoaderData(); - const fetcher = useTypedFetcher(); - const results = fetcher.data; - const { replace: replaceSearchParams } = useSearchParams(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); + const plan = useCurrentPlan(); + const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; // Use most recent history item if available, otherwise fall back to defaults const initialQuery = history.length > 0 ? history[0].query : defaultQuery; - const initialScope: QueryScope = history.length > 0 ? history[0].scope : "environment"; + const initialScope = history.length > 0 ? history[0].scope : "environment"; const initialTimeFilter = history.length > 0 ? { period: history[0].filterPeriod ?? undefined, - // Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined, to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined, } : undefined; - const editorRef = useRef(null); - const [prettyFormatting, setPrettyFormatting] = useState(true); - const [resultsView, setResultsView] = useState<"table" | "graph">("table"); - const [chartConfig, setChartConfig] = useState(defaultChartConfig); - const [sidebarTab, setSidebarTab] = useState("ai"); - const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null); - - // Save to dashboard dialog state - const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); - - // Title generation state - const titleFetcher = useFetcher(); - const isTitleLoading = titleFetcher.state !== "idle"; - const generatedTitle = titleFetcher.data?.title; - const [historyTitle, setHistoryTitle] = useState( - history.length > 0 ? history[0].title ?? null : null - ); - - // Effective title: history title takes precedence, then generated - const queryTitle = historyTitle ?? generatedTitle ?? null; - - // Track whether we should generate a title for the current results - const [shouldGenerateTitle, setShouldGenerateTitle] = useState(false); - - // Trigger title generation when query succeeds (only for new queries, not history) - useEffect(() => { - if ( - results?.rows && - !results.error && - results.queryId && - shouldGenerateTitle && - !historyTitle && - titleFetcher.state === "idle" - ) { - const currentQuery = editorRef.current?.getQuery(); - if (currentQuery) { - titleFetcher.submit( - { query: currentQuery, queryId: results.queryId }, - { - method: "POST", - action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-title`, - encType: "application/json", - } - ); - setShouldGenerateTitle(false); - } - } - }, [ - results, - shouldGenerateTitle, - historyTitle, - titleFetcher, - organization.slug, - project.slug, - environment.slug, - ]); - - const handleTryFixError = useCallback((errorMessage: string) => { - setSidebarTab("ai"); - setAiFixRequest((prev) => ({ - prompt: `Fix this query error: ${errorMessage}`, - key: (prev?.key ?? 0) + 1, - })); - }, []); - - // Handle time filter changes from AI - const handleTimeFilterChange = useCallback( - (filter: AITimeFilter) => { - replaceSearchParams({ - period: filter.period, - from: filter.from, - to: filter.to, - // Clear cursor/direction when time filter changes - cursor: undefined, - direction: undefined, - }); - }, - [replaceSearchParams] - ); - - const isLoading = fetcher.state === "submitting" || fetcher.state === "loading"; - - // Create a stable key from columns to detect schema changes - const columnsKey = results?.columns - ? results.columns.map((c) => `${c.name}:${c.type}`).join(",") - : ""; - - // Reset chart config only when column schema actually changes - // This allows re-running queries with different WHERE clauses without losing config - useEffect(() => { - if (columnsKey) { - setChartConfig(defaultChartConfig); - } - }, [columnsKey]); - - const handleChartConfigChange = useCallback((config: ChartConfiguration) => { - setChartConfig(config); - }, []); - - // Handle query submission - prepare for title generation - const handleQuerySubmit = useCallback(() => { - setHistoryTitle(null); // Clear history title when running a new query - setShouldGenerateTitle(true); // Enable title generation for new results - }, []); - - // Handle history selection - use existing title if available - const handleHistorySelected = useCallback((item: QueryHistoryItem) => { - setHistoryTitle(item.title ?? null); - setShouldGenerateTitle(false); // Don't generate title for history items - }, []); - - return ( - - - Query} /> - - - - - - {/* Query editor - isolated component to prevent re-renders */} - - - - - {/* Results */} - - setResultsView(v as "table" | "graph")} - className="grid h-full max-h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden" - > - - - Table - - - Graph - - {results?.rows ? ( -
-
- {results.reachedMaxRows ? ( - - ) : ( - - {results.rows.length > 0 - ? `${results.rows.length.toLocaleString()} Results` - : "Results"} - - )} - {results?.stats && ( - - {formatDurationNanoseconds(parseInt(results.stats.elapsed_ns, 10))} - - )} -
-
- {results?.rows && results?.columns && results.rows.length > 0 && ( - - )} - {resultsView === "table" && ( - - )} -
-
- ) : null} -
- - {isLoading ? ( -
- - Executing query... -
- ) : results?.error ? ( -
-
-                          {results.error}
-                        
- -
- ) : results?.explainOutput ? ( -
- {results.generatedSql && ( -
- Generated ClickHouse SQL -
-
-                                {results.generatedSql}
-                              
-
-
- )} -
- Query Execution Plan -
-
-                              {results.explainOutput}
-                            
-
-
-
- ) : results?.rows && results?.columns ? ( -
- -
- - } - data={{ - rows: results.rows, - columns: results.columns, - }} - config={{ - type: "table", - prettyFormatting, - sorting: [], - }} - accessory={ - setIsSaveDialogOpen(true)} - /> - } - content="Save to dashboard" - /> - } - /> -
-
- ) : ( - - Run a query to see results here. - - )} -
- 0 && - hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped) - ? "grid-rows-[auto_1fr]" - : "grid-rows-[1fr]" - }`} - > - {results?.rows && results?.columns && results.rows.length > 0 ? ( - <> - - setIsSaveDialogOpen(true)} - /> - - ) : ( - - Run a query to visualize results. - - )} - -
-
-
-
- - - { - editorRef.current?.setQuery(exampleQuery); - editorRef.current?.setScope(exampleScope); - }} - onQueryGenerated={(query) => { - const formatted = autoFormatSQL(query); - editorRef.current?.setQuery(formatted); - }} - onTimeFilterChange={handleTimeFilterChange} - getCurrentQuery={() => editorRef.current?.getQuery() ?? ""} - activeTab={sidebarTab} - onTabChange={setSidebarTab} - aiFixRequest={aiFixRequest} - /> - -
-
- -
+ // Build the query action URL for this page + const queryActionUrl = queryPath( + { slug: organization.slug }, + { slug: project.slug }, + { slug: environment.slug } ); -} - -function QueryTitle({ isTitleLoading, title }: { isTitleLoading: boolean; title: string | null }) { - if (isTitleLoading) - return ( - - Generating title... - - ); - - return title ?? "Results"; -} - -function ExportResultsButton({ - rows, - columns, -}: { - rows: Record[]; - columns: OutputColumnMetadata[]; -}) { - const [isOpen, setIsOpen] = useState(false); - - const handleCopyCSV = () => { - const csv = rowsToCSV(rows, columns); - navigator.clipboard.writeText(csv); - setIsOpen(false); - }; - - const handleExportCSV = () => { - const csv = rowsToCSV(rows, columns); - const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, ""); - downloadFile(csv, `query-results-${timestamp}.csv`, "text/csv"); - setIsOpen(false); - }; - - const handleCopyJSON = () => { - const json = rowsToJSON(rows); - navigator.clipboard.writeText(json); - setIsOpen(false); - }; - - const handleExportJSON = () => { - const json = rowsToJSON(rows); - const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, ""); - downloadFile(json, `query-results-${timestamp}.json`, "application/json"); - setIsOpen(false); - }; - - return ( - - - Export - - -
- - - - -
-
-
- ); -} - -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 ( - <> - Env: - - ); - default: - return scope; - } -} - -function QueryResultsCallouts({ - hiddenColumns, - periodClipped, - organizationSlug, -}: { - hiddenColumns: string[] | null | undefined; - periodClipped: number | null; - organizationSlug: string; -}) { - const hasCallouts = (hiddenColumns && hiddenColumns.length > 0) || periodClipped; - - if (!hasCallouts) { - return null; - } - - return ( -
- {hiddenColumns && hiddenColumns.length > 0 && ( - - SELECT * doesn't return all columns because it's slow. The following columns - are not shown: {hiddenColumns.join(", ")}. - Specify them explicitly to include them. - - )} - {periodClipped && ( - - Upgrade - - } - className="items-center" - > - {simplur`Results are limited to the last ${periodClipped} day[|s] based on your plan.`} - - )} -
- ); -} - -function hasQueryResultsCallouts( - hiddenColumns: string[] | null | undefined, - periodClipped: number | null -): boolean { - return (hiddenColumns && hiddenColumns.length > 0) || !!periodClipped; -} -function ResultsChart({ - rows, - columns, - chartConfig, - onChartConfigChange, - queryTitle, - isTitleLoading, - onSaveClick, -}: { - rows: Record[]; - columns: OutputColumnMetadata[]; - chartConfig: ChartConfiguration; - onChartConfigChange: (config: ChartConfiguration) => void; - queryTitle: string | null; - isTitleLoading: boolean; - onSaveClick: () => void; -}) { return ( - <> - - -
- } - data={{ - rows, - columns, - }} - config={{ - type: "chart", - ...chartConfig, - }} - accessory={ - - } - content="Save to dashboard" - /> - } - /> -
-
- - - - -
- + ); } diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index 2e6635f144..b476a38f27 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -1,20 +1,32 @@ +import type { OutputColumnMetadata } from "@internal/clickhouse"; import { useFetcher } from "@remix-run/react"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { z } from "zod"; -import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; -import { Button } from "~/components/primitives/Buttons"; -import { FormError } from "~/components/primitives/FormError"; -import { Label } from "~/components/primitives/Label"; -import { Spinner } from "~/components/primitives/Spinner"; import { requireUserId } from "~/services/session.server"; import { hasAccessToEnvironment } from "~/models/runtimeEnvironment.server"; import { executeQuery } from "~/services/queryService.server"; -import { QueryWidget, QueryWidgetConfig } from "~/components/metrics/QueryWidget"; +import { QueryWidget, type QueryWidgetConfig } from "~/components/metrics/QueryWidget"; import { useInterval } from "~/hooks/useInterval"; 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[]; + columns: OutputColumnMetadata[]; + stats: { elapsed_ns: string } | null; + hiddenColumns: string[] | null; + reachedMaxRows: boolean; + periodClipped: number | null; + maxQueryPeriod: number | undefined; + }; + }; + const MetricWidgetQuery = z.object({ query: z.string(), organizationId: z.string(), @@ -123,6 +135,8 @@ type MetricWidgetProps = { refreshIntervalMs?: number; isResizing?: boolean; isDraggable?: boolean; + /** Callback when edit button is clicked */ + onEdit?: () => void; } & z.infer; export function MetricWidget({ @@ -132,12 +146,10 @@ export function MetricWidget({ refreshIntervalMs, isResizing, isDraggable, + onEdit, ...props }: MetricWidgetProps) { - // Use a unique key for each widget's fetcher to prevent "Expected fetch controller" errors - // when navigating between dashboards. Without a key, Remix can't properly track and clean up - // fetchers when components unmount during navigation. - const fetcher = useFetcher({ key: `metric-widget-${widgetKey}` }); + const fetcher = useFetcher(); const isLoading = fetcher.state !== "idle"; const submit = useCallback(async () => { @@ -169,6 +181,7 @@ export function MetricWidget({ error={fetcher.data?.success === false ? fetcher.data.error : undefined} isResizing={isResizing} isDraggable={isDraggable} + onEdit={onEdit} /> ); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx index 5cbb5aafbc..25cd2fa1ec 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx @@ -4,6 +4,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; import { findProjectBySlug } from "~/models/project.server"; +import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; @@ -36,27 +37,6 @@ const ParamsSchema = EnvironmentParamSchema.extend({ dashboardId: z.string(), }); -// Layout item schema for parsing existing layout -const LayoutItemSchema = z.object({ - i: z.string(), - x: z.number(), - y: z.number(), - w: z.number(), - h: z.number(), -}); - -const WidgetSchema = z.object({ - title: z.string(), - query: z.string(), - display: QueryWidgetConfig, -}); - -const DashboardLayoutSchema = z.object({ - version: z.literal("1"), - layout: z.array(LayoutItemSchema), - widgets: z.record(WidgetSchema), -}); - export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam, dashboardId } = ParamsSchema.parse(params); @@ -93,10 +73,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const { title, query, config } = result.data; // Parse existing layout - let existingLayout: z.infer; + let existingLayout: z.infer; try { const parsed = JSON.parse(dashboard.layout); - const layoutResult = DashboardLayoutSchema.safeParse(parsed); + const layoutResult = DashboardLayout.safeParse(parsed); if (!layoutResult.success) { // If parsing fails, start with empty layout existingLayout = { diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx new file mode 100644 index 0000000000..065cc711e7 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx @@ -0,0 +1,121 @@ +import { type ActionFunctionArgs, json } from "@remix-run/node"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; +import { findProjectBySlug } from "~/models/project.server"; +import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; + +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 ParamsSchema = EnvironmentParamSchema.extend({ + dashboardId: z.string(), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, dashboardId } = ParamsSchema.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 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; + + // Parse existing layout + let existingLayout: z.infer; + try { + const parsed = JSON.parse(dashboard.layout); + const layoutResult = DashboardLayout.safeParse(parsed); + if (!layoutResult.success) { + throw new Response("Dashboard layout is corrupt", { status: 500 }); + } + existingLayout = layoutResult.data; + } catch (e) { + if (e instanceof Response) throw e; + throw new Response("Failed to parse dashboard layout", { status: 500 }); + } + + // 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + // Return success (the client will handle closing the editor) + return json({ success: true }); +}; From 4bec682b3e043dc1ba35dfb09e220f1a09ab6c39 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 3 Feb 2026 22:02:09 -0800 Subject: [PATCH 028/131] The data now gets passed through --- .../app/components/metrics/QueryWidget.tsx | 22 +++++++++++++++---- .../app/components/query/QueryEditor.tsx | 20 ++++++++++++++++- .../route.tsx | 14 +++++------- .../route.tsx | 13 +++++------ apps/webapp/app/routes/resources.metric.tsx | 10 ++++++--- 5 files changed, 55 insertions(+), 24 deletions(-) diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index 1d4c69c83a..d570700406 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -64,11 +64,21 @@ export const QueryWidgetConfig = z.discriminatedUnion("type", [ export type QueryWidgetConfig = z.infer; -type QueryWidgetData = { +/** 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; isLoading?: boolean; @@ -78,8 +88,8 @@ export type QueryWidgetProps = { accessory?: ReactNode; isResizing?: boolean; isDraggable?: boolean; - /** Callback when edit button is clicked. When provided, shows edit button on hover. */ - onEdit?: () => void; + /** Callback when edit button is clicked. Receives the current data. When provided, shows edit button on hover. */ + onEdit?: (data: QueryWidgetData) => void; }; export function QueryWidget({ @@ -110,7 +120,11 @@ export function QueryWidget({ + - ); @@ -538,12 +539,9 @@ export function QueryEditor({ - - ); @@ -591,7 +589,10 @@ export function QueryEditor({ onValueChange={(v) => setResultsView(v as "table" | "graph")} className="grid h-full max-h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden" > - + Table @@ -711,16 +712,29 @@ export function QueryEditor({ }} accessory={ mode.type === "standalone" ? ( - setIsSaveDialogOpen(true)} - /> - } - content="Save to dashboard" - /> + + ) : mode.type === "dashboard-add" ? ( + + ) : mode.type === "dashboard-edit" ? ( + ) : undefined } /> @@ -756,8 +770,35 @@ export function QueryEditor({ onChartConfigChange={handleChartConfigChange} queryTitle={queryTitle} isTitleLoading={isTitleLoading} - onSaveClick={ - mode.type === "standalone" ? () => setIsSaveDialogOpen(true) : undefined + accessory={ + mode.type === "standalone" ? ( + setIsSaveDialogOpen(true)} + /> + } + content="Save to dashboard" + /> + ) : mode.type === "dashboard-add" ? ( + + ) : mode.type === "dashboard-edit" ? ( + + ) : undefined } /> @@ -962,7 +1003,7 @@ function ResultsChart({ onChartConfigChange, queryTitle, isTitleLoading, - onSaveClick, + accessory, }: { rows: Record[]; columns: OutputColumnMetadata[]; @@ -970,7 +1011,7 @@ function ResultsChart({ onChartConfigChange: (config: ChartConfiguration) => void; queryTitle: string | null; isTitleLoading: boolean; - onSaveClick?: () => void; + accessory?: ReactNode; }) { return ( <> @@ -987,20 +1028,7 @@ function ResultsChart({ type: "chart", ...chartConfig, }} - accessory={ - onSaveClick ? ( - - } - content="Save to dashboard" - /> - ) : undefined - } + accessory={accessory} /> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 4f2a06b96b..8785c0e0e0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -272,21 +272,6 @@ export default function Page() { }; }, [layoutJson]); - // Close editor when add/update operation completes - useEffect(() => { - if ( - (addWidgetFetcher.state === "idle" && addWidgetFetcher.data) || - (updateWidgetFetcher.state === "idle" && updateWidgetFetcher.data) - ) { - setEditorMode(null); - } - }, [ - addWidgetFetcher.state, - addWidgetFetcher.data, - updateWidgetFetcher.state, - updateWidgetFetcher.data, - ]); - const handleLayoutChange = useCallback( (newLayout: LayoutItem[]) => { // Skip if not yet initialized (prevents saving during mount/navigation) @@ -334,6 +319,8 @@ export default function Page() { action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/add-widget`, } ); + // Close editor immediately (optimistic) + setEditorMode(null); } else if (editorMode?.type === "edit") { // Submit to update-widget action updateWidgetFetcher.submit( @@ -348,6 +335,8 @@ export default function Page() { action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/update-widget`, } ); + // Close editor immediately (optimistic) + setEditorMode(null); } }, [ From 012ed25f0639651549d6a44baf83758436a17107 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 4 Feb 2026 16:47:32 -0800 Subject: [PATCH 033/131] New revalidate --- .../app/components/query/QueryEditor.tsx | 74 +++++----------- apps/webapp/app/hooks/useRevalidateOnParam.ts | 57 ++++++++++++ .../route.tsx | 88 ++++++++----------- ...ram.dashboards.$dashboardId.add-widget.tsx | 23 +++-- ....dashboards.$dashboardId.update-widget.tsx | 22 +++-- 5 files changed, 147 insertions(+), 117 deletions(-) create mode 100644 apps/webapp/app/hooks/useRevalidateOnParam.ts diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 4b58b009eb..2a7ea79fa9 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -112,6 +112,13 @@ export type QueryEditorMode = widgetName: string; }; +/** Data passed to the save render prop */ +export type QueryEditorSaveData = { + title: string; + query: string; + config: QueryWidgetConfig; +}; + export type QueryEditorProps = { // Default values - used to initialize state defaultQuery: string; @@ -137,8 +144,8 @@ export type QueryEditorProps = { // Max period days (from plan) maxPeriodDays?: number; - // Callbacks - onSave?: (data: { title: string; query: string; config: QueryWidgetConfig }) => void; + // Render prop for save functionality - receives current data, returns ReactNode + save?: (data: QueryEditorSaveData) => ReactNode; onClose?: () => void; }; @@ -350,7 +357,7 @@ export function QueryEditor({ queryActionUrl, mode, maxPeriodDays, - onSave, + save, onClose, }: QueryEditorProps) { const fetcher = useTypedFetcher(); @@ -494,25 +501,16 @@ export function QueryEditor({ setShouldGenerateTitle(false); // Don't generate title for history items }, []); - // Handle save for dashboard modes - const handleSave = useCallback(() => { - if (!onSave) return; - - const currentQuery = editorRef.current?.getQuery() ?? ""; - const config: QueryWidgetConfig = + // Compute current save data for the save render prop + const currentQuery = editorRef.current?.getQuery() ?? ""; + const saveData: QueryEditorSaveData = { + title: queryTitle ?? "Untitled Query", + query: currentQuery, + config: resultsView === "table" ? { type: "table", prettyFormatting, sorting: [] } - : { type: "chart", ...chartConfig }; - - onSave({ - title: queryTitle ?? "Untitled Query", - query: currentQuery, - config, - }); - }, [onSave, resultsView, prettyFormatting, chartConfig, queryTitle]); - - // Determine if save button should be enabled - const canSave = results?.rows && results.rows.length > 0 && !results.error; + : { type: "chart", ...chartConfig }, + }; // Render NavBar based on mode const renderNavBar = () => { @@ -719,22 +717,8 @@ export function QueryEditor({ > Save to dashboard - ) : mode.type === "dashboard-add" ? ( - - ) : mode.type === "dashboard-edit" ? ( - + ) : save ? ( + save(saveData) ) : undefined } /> @@ -782,22 +766,8 @@ export function QueryEditor({ } content="Save to dashboard" /> - ) : mode.type === "dashboard-add" ? ( - - ) : mode.type === "dashboard-edit" ? ( - + ) : save ? ( + save(saveData) ) : undefined } /> diff --git a/apps/webapp/app/hooks/useRevalidateOnParam.ts b/apps/webapp/app/hooks/useRevalidateOnParam.ts new file mode 100644 index 0000000000..de05141d95 --- /dev/null +++ b/apps/webapp/app/hooks/useRevalidateOnParam.ts @@ -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]); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 8785c0e0e0..aeeff3bd74 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -40,11 +40,12 @@ import { requireUser, requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, queryPath, v3BuiltInDashboardPath } from "~/utils/pathBuilder"; import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route"; import { IconEdit } from "@tabler/icons-react"; -import { QueryEditor } from "~/components/query/QueryEditor"; -import type { QueryWidgetConfig, WidgetData } from "~/components/metrics/QueryWidget"; +import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor"; +import type { WidgetData } from "~/components/metrics/QueryWidget"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { useRevalidateOnParam } from "~/hooks/useRevalidateOnParam"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { defaultChartConfig } from "~/components/code/ChartConfigPanel"; @@ -230,8 +231,6 @@ export default function Page() { const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; const fetcher = useFetcher(); - const addWidgetFetcher = useFetcher(); - const updateWidgetFetcher = useFetcher(); const debounceTimeoutRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(layout.layout)); @@ -239,6 +238,12 @@ export default function Page() { // Editor mode state const [editorMode, setEditorMode] = useState(null); + // Revalidate when redirected back with _revalidate param, and close the editor + useRevalidateOnParam({ + param: "_revalidate", + onRevalidate: () => setEditorMode(null), + }); + // Build the query action URL const queryActionUrl = queryPath( { slug: organization.slug }, @@ -304,56 +309,37 @@ export default function Page() { setEditorMode({ type: "edit", widgetId, widget }); }, []); - const handleSave = useCallback( - (data: { title: string; query: string; config: QueryWidgetConfig }) => { - if (editorMode?.type === "add") { - // Submit to add-widget action - addWidgetFetcher.submit( - { - title: data.title, - query: data.query, - config: JSON.stringify(data.config), - }, - { - method: "POST", - action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/add-widget`, - } - ); - // Close editor immediately (optimistic) - setEditorMode(null); - } else if (editorMode?.type === "edit") { - // Submit to update-widget action - updateWidgetFetcher.submit( - { - widgetId: editorMode.widgetId, - title: data.title, - query: data.query, - config: JSON.stringify(data.config), - }, - { - method: "POST", - action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/update-widget`, - } - ); - // Close editor immediately (optimistic) - setEditorMode(null); - } - }, - [ - editorMode, - addWidgetFetcher, - updateWidgetFetcher, - organization.slug, - project.slug, - environment.slug, - friendlyId, - ] - ); - const handleCloseEditor = useCallback(() => { setEditorMode(null); }, []); + // Build the action URLs for add/update widget + const addWidgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/add-widget`; + const updateWidgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/update-widget`; + + // Render save form for the QueryEditor + const renderSaveForm = useCallback( + (data: QueryEditorSaveData) => { + const isAdd = editorMode?.type === "add"; + const actionUrl = isAdd ? addWidgetActionUrl : updateWidgetActionUrl; + + return ( +
+ {editorMode?.type === "edit" && ( + + )} + + + + +
+ ); + }, + [editorMode, addWidgetActionUrl, updateWidgetActionUrl] + ); + // Prepare editor props when in editor mode const editorProps = editorMode ? (() => { @@ -455,7 +441,7 @@ export default function Page() { queryActionUrl={queryActionUrl} mode={editorProps.mode} maxPeriodDays={maxPeriodDays} - onSave={handleSave} + save={renderSaveForm} onClose={handleCloseEditor} /> )} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx index 25cd2fa1ec..54e92db7b5 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx @@ -1,8 +1,9 @@ -import { redirect, type ActionFunctionArgs } from "@remix-run/node"; +import { type ActionFunctionArgs } from "@remix-run/node"; import { nanoid } from "nanoid"; import { z } from "zod"; import { prisma } from "~/db.server"; import { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; +import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; import { requireUserId } from "~/services/session.server"; @@ -142,13 +143,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); - // Redirect to the dashboard - return redirect( - v3CustomDashboardPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam }, - { friendlyId: dashboardId } - ) + // Redirect with _revalidate param to trigger revalidation on the dashboard page + const dashboardPath = v3CustomDashboardPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: dashboardId } + ); + + return redirectWithSuccessMessage( + `${dashboardPath}?_revalidate=${Date.now()}`, + request, + `Added "${title}" to dashboard` ); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx index 065cc711e7..3c786d33d7 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx @@ -1,11 +1,12 @@ -import { type ActionFunctionArgs, json } from "@remix-run/node"; +import { type ActionFunctionArgs } from "@remix-run/node"; import { z } from "zod"; import { prisma } from "~/db.server"; import { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; +import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; const UpdateWidgetSchema = z.object({ widgetId: z.string().min(1, "Widget ID is required"), @@ -39,7 +40,7 @@ const ParamsSchema = EnvironmentParamSchema.extend({ export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam, dashboardId } = ParamsSchema.parse(params); + const { organizationSlug, projectParam, envParam, dashboardId } = ParamsSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { @@ -116,6 +117,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); - // Return success (the client will handle closing the editor) - return json({ success: true }); + // Redirect with _revalidate param to trigger revalidation on the dashboard page + const dashboardPath = v3CustomDashboardPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: dashboardId } + ); + + return redirectWithSuccessMessage( + `${dashboardPath}?_revalidate=${Date.now()}`, + request, + `Updated "${title}"` + ); }; From a6074fedaf0585050199e7990fb3006876164980 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 4 Feb 2026 17:32:16 -0800 Subject: [PATCH 034/131] Added duplication and working on fixes --- .../app/components/metrics/QueryWidget.tsx | 78 ++++++++--- .../route.tsx | 10 ++ .../route.tsx | 44 +++++-- apps/webapp/app/routes/resources.metric.tsx | 8 ++ ...ram.dashboards.$dashboardId.add-widget.tsx | 18 +-- ....dashboards.$dashboardId.delete-widget.tsx | 86 ++++++++++++ ...shboards.$dashboardId.duplicate-widget.tsx | 123 ++++++++++++++++++ ....dashboards.$dashboardId.update-widget.tsx | 18 +-- 8 files changed, 328 insertions(+), 57 deletions(-) create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.delete-widget.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.duplicate-widget.tsx diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index 0544ad66ba..361a027b6b 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -7,12 +7,22 @@ import { TSQLResultsTable } from "../code/TSQLResultsTable"; import { QueryResultsChart } from "../code/QueryResultsChart"; import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog"; import { Button } from "../primitives/Buttons"; -import { ArrowsPointingOutIcon, PencilSquareIcon } from "@heroicons/react/20/solid"; +import { + ArrowsPointingOutIcon, + DocumentDuplicateIcon, + PencilSquareIcon, + TrashIcon, +} from "@heroicons/react/20/solid"; import { LoadingBarDivider } from "../primitives/LoadingBarDivider"; import { Callout } from "../primitives/Callout"; import { ChartBarIcon } from "@heroicons/react/24/solid"; import { cn } from "~/utils/cn"; -import { SimpleTooltip } from "../primitives/Tooltip"; +import { + Popover, + PopoverContent, + PopoverMenuItem, + PopoverVerticalEllipseTrigger, +} from "../primitives/Popover"; const ChartType = z.union([z.literal("bar"), z.literal("line")]); export type ChartType = z.infer; @@ -88,8 +98,12 @@ export type QueryWidgetProps = { accessory?: ReactNode; isResizing?: boolean; isDraggable?: boolean; - /** Callback when edit button is clicked. Receives the current data. When provided, shows edit button on hover. */ + /** Callback when edit is clicked. Receives the current data. */ onEdit?: (data: QueryWidgetData) => void; + /** Callback when delete is clicked. */ + onDelete?: () => void; + /** Callback when duplicate is clicked. Receives the current data. */ + onDuplicate?: (data: QueryWidgetData) => void; }; export function QueryWidget({ @@ -100,9 +114,14 @@ export function QueryWidget({ isResizing, isDraggable, onEdit, + onDelete, + onDuplicate, ...props }: QueryWidgetProps) { const [isFullscreen, setIsFullscreen] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const hasMenu = onEdit || onDelete || onDuplicate; return (
@@ -111,25 +130,50 @@ export function QueryWidget({
{title}
{accessory} - {onEdit && ( - onEdit(props.data)} - /> - } - content="Edit" - /> - )}
))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index aeeff3bd74..db51590f07 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -1,7 +1,7 @@ import { 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, useFetcher, useNavigation } from "@remix-run/react"; +import { Form, useFetcher, useNavigation, useRevalidator } from "@remix-run/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -45,7 +45,6 @@ import type { WidgetData } from "~/components/metrics/QueryWidget"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { useRevalidateOnParam } from "~/hooks/useRevalidateOnParam"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { defaultChartConfig } from "~/components/code/ChartConfigPanel"; @@ -231,6 +230,8 @@ export default function Page() { const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; const fetcher = useFetcher(); + const widgetActionFetcher = useFetcher(); + const { revalidate } = useRevalidator(); const debounceTimeoutRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(layout.layout)); @@ -238,11 +239,14 @@ export default function Page() { // Editor mode state const [editorMode, setEditorMode] = useState(null); - // Revalidate when redirected back with _revalidate param, and close the editor - useRevalidateOnParam({ - param: "_revalidate", - onRevalidate: () => setEditorMode(null), - }); + // Revalidate when widget action (delete/duplicate/add/update) completes + useEffect(() => { + if (widgetActionFetcher.state === "idle" && widgetActionFetcher.data) { + revalidate(); + // Close the editor if it was open (for add/update operations) + setEditorMode(null); + } + }, [widgetActionFetcher.state, widgetActionFetcher.data]); // Build the query action URL const queryActionUrl = queryPath( @@ -309,6 +313,24 @@ export default function Page() { setEditorMode({ type: "edit", widgetId, widget }); }, []); + // Build the action URLs for delete/duplicate widget + const deleteWidgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/delete-widget`; + const duplicateWidgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/duplicate-widget`; + + const handleDeleteWidget = useCallback( + (widgetId: string) => { + widgetActionFetcher.submit({ widgetId }, { method: "POST", action: deleteWidgetActionUrl }); + }, + [widgetActionFetcher, deleteWidgetActionUrl] + ); + + const handleDuplicateWidget = useCallback( + (widgetId: string) => { + widgetActionFetcher.submit({ widgetId }, { method: "POST", action: duplicateWidgetActionUrl }); + }, + [widgetActionFetcher, duplicateWidgetActionUrl] + ); + const handleCloseEditor = useCallback(() => { setEditorMode(null); }, []); @@ -324,7 +346,7 @@ export default function Page() { const actionUrl = isAdd ? addWidgetActionUrl : updateWidgetActionUrl; return ( -
+ {editorMode?.type === "edit" && ( )} @@ -334,10 +356,10 @@ export default function Page() { - +
); }, - [editorMode, addWidgetActionUrl, updateWidgetActionUrl] + [editorMode, addWidgetActionUrl, updateWidgetActionUrl, widgetActionFetcher] ); // Prepare editor props when in editor mode @@ -415,6 +437,8 @@ export default function Page() { editable={true} onLayoutChange={handleLayoutChange} onEditWidget={handleEditWidget} + onDeleteWidget={handleDeleteWidget} + onDuplicateWidget={handleDuplicateWidget} /> diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index 4a4a6bad5c..7bd378aff0 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -141,6 +141,10 @@ type MetricWidgetProps = { isDraggable?: boolean; /** Callback when edit button is clicked - receives current data */ onEdit?: (data: QueryWidgetData) => void; + /** Callback when delete is clicked */ + onDelete?: () => void; + /** Callback when duplicate is clicked - receives current data */ + onDuplicate?: (data: QueryWidgetData) => void; } & z.infer; export function MetricWidget({ @@ -151,6 +155,8 @@ export function MetricWidget({ isResizing, isDraggable, onEdit, + onDelete, + onDuplicate, ...props }: MetricWidgetProps) { const fetcher = useFetcher(); @@ -186,6 +192,8 @@ export function MetricWidget({ isResizing={isResizing} isDraggable={isDraggable} onEdit={onEdit} + onDelete={onDelete} + onDuplicate={onDuplicate} /> ); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx index 54e92db7b5..3074f2ef51 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx @@ -1,13 +1,13 @@ 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 { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; const AddWidgetSchema = z.object({ title: z.string().min(1, "Title is required"), @@ -143,17 +143,5 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); - // Redirect with _revalidate param to trigger revalidation on the dashboard page - const dashboardPath = v3CustomDashboardPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam }, - { friendlyId: dashboardId } - ); - - return redirectWithSuccessMessage( - `${dashboardPath}?_revalidate=${Date.now()}`, - request, - `Added "${title}" to dashboard` - ); + return typedjson({ success: true, addedTitle: title }); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.delete-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.delete-widget.tsx new file mode 100644 index 0000000000..23710bfa88 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.delete-widget.tsx @@ -0,0 +1,86 @@ +import { type ActionFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { typedjson } from "remix-typedjson"; +import { prisma } from "~/db.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; + +const DeleteWidgetSchema = z.object({ + widgetId: z.string().min(1, "Widget ID is required"), +}); + +const ParamsSchema = EnvironmentParamSchema.extend({ + dashboardId: z.string(), +}); + +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 }); + } + + // 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 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; + + // Parse existing layout + let existingLayout: z.infer; + try { + const parsed = JSON.parse(dashboard.layout); + const layoutResult = DashboardLayout.safeParse(parsed); + if (!layoutResult.success) { + throw new Response("Invalid dashboard layout", { status: 500 }); + } + existingLayout = layoutResult.data; + } catch { + throw new Response("Failed to parse dashboard layout", { status: 500 }); + } + + // 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true, deletedTitle: widgetTitle }); +}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.duplicate-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.duplicate-widget.tsx new file mode 100644 index 0000000000..843387d4be --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.duplicate-widget.tsx @@ -0,0 +1,123 @@ +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 { findProjectBySlug } from "~/models/project.server"; +import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; + +const DuplicateWidgetSchema = z.object({ + widgetId: z.string().min(1, "Widget ID is required"), +}); + +const ParamsSchema = EnvironmentParamSchema.extend({ + dashboardId: z.string(), +}); + +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 }); + } + + // 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 rawData = { + widgetId: formData.get("widgetId"), + }; + + const result = DuplicateWidgetSchema.safeParse(rawData); + if (!result.success) { + throw new Response("Invalid form data: " + result.error.message, { status: 400 }); + } + + const { widgetId } = result.data; + + // Parse existing layout + let existingLayout: z.infer; + try { + const parsed = JSON.parse(dashboard.layout); + const layoutResult = DashboardLayout.safeParse(parsed); + if (!layoutResult.success) { + throw new Response("Invalid dashboard layout", { status: 500 }); + } + existingLayout = layoutResult.data; + } catch { + throw new Response("Failed to parse dashboard layout", { status: 500 }); + } + + // 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 }); + } + + // Generate new widget ID + const newWidgetId = 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true, duplicatedTitle: originalWidget.title }); +}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx index 3c786d33d7..bce6565d23 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx @@ -1,12 +1,12 @@ import { type ActionFunctionArgs } from "@remix-run/node"; import { z } from "zod"; +import { typedjson } from "remix-typedjson"; import { prisma } from "~/db.server"; import { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; -import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; const UpdateWidgetSchema = z.object({ widgetId: z.string().min(1, "Widget ID is required"), @@ -117,17 +117,5 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); - // Redirect with _revalidate param to trigger revalidation on the dashboard page - const dashboardPath = v3CustomDashboardPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam }, - { friendlyId: dashboardId } - ); - - return redirectWithSuccessMessage( - `${dashboardPath}?_revalidate=${Date.now()}`, - request, - `Updated "${title}"` - ); + return typedjson({ success: true, updatedTitle: title }); }; From 0601b9cf3dada0a3409a3986d8bb1d4b6e7a3fdc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 4 Feb 2026 21:45:03 -0800 Subject: [PATCH 035/131] Consolidate to a single resource route --- .../route.tsx | 72 ++-- apps/webapp/app/routes/resources.metric.tsx | 2 +- ...ram.dashboards.$dashboardId.add-widget.tsx | 147 -------- ....dashboards.$dashboardId.delete-widget.tsx | 86 ----- ...shboards.$dashboardId.duplicate-widget.tsx | 123 ------ ....dashboards.$dashboardId.update-widget.tsx | 121 ------ ...vParam.dashboards.$dashboardId.widgets.tsx | 355 ++++++++++++++++++ 7 files changed, 391 insertions(+), 515 deletions(-) delete mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx delete mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.delete-widget.tsx delete mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.duplicate-widget.tsx delete mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index db51590f07..2032a60566 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -281,72 +281,70 @@ export default function Page() { }; }, [layoutJson]); - const handleLayoutChange = useCallback( - (newLayout: LayoutItem[]) => { - // Skip if not yet initialized (prevents saving during mount/navigation) - if (!isInitializedRef.current) { - return; - } + const handleLayoutChange = useCallback((newLayout: LayoutItem[]) => { + // Skip if not yet initialized (prevents saving during mount/navigation) + if (!isInitializedRef.current) { + return; + } - const newLayoutJson = JSON.stringify(newLayout); + const newLayoutJson = JSON.stringify(newLayout); - // Skip if layout hasn't actually changed - if (newLayoutJson === currentLayoutJsonRef.current) { - return; - } + // Skip if layout hasn't actually changed + if (newLayoutJson === currentLayoutJsonRef.current) { + return; + } - // Clear existing timeout - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } + // Clear existing timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } - // Debounce auto-save by 500ms - debounceTimeoutRef.current = setTimeout(() => { - currentLayoutJsonRef.current = newLayoutJson; - fetcher.submit({ action: "layout", layout: newLayoutJson }, { method: "POST" }); - }, 500); - }, - [fetcher] - ); + // Debounce auto-save by 500ms + debounceTimeoutRef.current = setTimeout(() => { + currentLayoutJsonRef.current = newLayoutJson; + fetcher.submit({ action: "layout", layout: newLayoutJson }, { method: "POST" }); + }, 500); + }, []); const handleEditWidget = useCallback((widgetId: string, widget: WidgetData) => { setEditorMode({ type: "edit", widgetId, widget }); }, []); - // Build the action URLs for delete/duplicate widget - const deleteWidgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/delete-widget`; - const duplicateWidgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/duplicate-widget`; + // Build the action URL for all widget operations + const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`; const handleDeleteWidget = useCallback( (widgetId: string) => { - widgetActionFetcher.submit({ widgetId }, { method: "POST", action: deleteWidgetActionUrl }); + widgetActionFetcher.submit( + { action: "delete", widgetId }, + { method: "POST", action: widgetActionUrl } + ); }, - [widgetActionFetcher, deleteWidgetActionUrl] + [widgetActionUrl] ); const handleDuplicateWidget = useCallback( (widgetId: string) => { - widgetActionFetcher.submit({ widgetId }, { method: "POST", action: duplicateWidgetActionUrl }); + widgetActionFetcher.submit( + { action: "duplicate", widgetId }, + { method: "POST", action: widgetActionUrl } + ); }, - [widgetActionFetcher, duplicateWidgetActionUrl] + [widgetActionUrl] ); const handleCloseEditor = useCallback(() => { setEditorMode(null); }, []); - // Build the action URLs for add/update widget - const addWidgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/add-widget`; - const updateWidgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/update-widget`; - // Render save form for the QueryEditor const renderSaveForm = useCallback( (data: QueryEditorSaveData) => { const isAdd = editorMode?.type === "add"; - const actionUrl = isAdd ? addWidgetActionUrl : updateWidgetActionUrl; return ( - + + {editorMode?.type === "edit" && ( )} @@ -359,7 +357,7 @@ export default function Page() { ); }, - [editorMode, addWidgetActionUrl, updateWidgetActionUrl, widgetActionFetcher] + [editorMode, widgetActionUrl] ); // Prepare editor props when in editor mode diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index 7bd378aff0..ca426f9457 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -168,7 +168,7 @@ export function MetricWidget({ action: `/resources/metric`, encType: "application/json", }); - }, [props]); + }, [JSON.stringify(props)]); // Reload periodically and on focus useInterval({ interval: refreshIntervalMs, callback: submit }); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx deleted file mode 100644 index 3074f2ef51..0000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.add-widget.tsx +++ /dev/null @@ -1,147 +0,0 @@ -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 { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; - -const AddWidgetSchema = z.object({ - 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 ParamsSchema = EnvironmentParamSchema.extend({ - dashboardId: z.string(), -}); - -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 }); - } - - // 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 rawData = { - 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; - - // Parse existing layout - let existingLayout: z.infer; - try { - const parsed = JSON.parse(dashboard.layout); - const layoutResult = DashboardLayout.safeParse(parsed); - if (!layoutResult.success) { - // If parsing fails, start with empty layout - existingLayout = { - version: "1", - layout: [], - widgets: {}, - }; - } else { - existingLayout = layoutResult.data; - } - } catch { - existingLayout = { - version: "1", - layout: [], - widgets: {}, - }; - } - - // Generate new widget ID - const widgetId = nanoid(8); - - // Calculate position at the bottom - // Find the maximum y + h from existing layout items - 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, reasonable height) - const newLayoutItem = { - i: widgetId, - x: 0, - y: maxBottom, - w: 12, - h: 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 - await prisma.metricsDashboard.update({ - where: { id: dashboard.id }, - data: { - layout: JSON.stringify(updatedLayout), - }, - }); - - return typedjson({ success: true, addedTitle: title }); -}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.delete-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.delete-widget.tsx deleted file mode 100644 index 23710bfa88..0000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.delete-widget.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { type ActionFunctionArgs } from "@remix-run/node"; -import { z } from "zod"; -import { typedjson } from "remix-typedjson"; -import { prisma } from "~/db.server"; -import { findProjectBySlug } from "~/models/project.server"; -import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; - -const DeleteWidgetSchema = z.object({ - widgetId: z.string().min(1, "Widget ID is required"), -}); - -const ParamsSchema = EnvironmentParamSchema.extend({ - dashboardId: z.string(), -}); - -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 }); - } - - // 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 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; - - // Parse existing layout - let existingLayout: z.infer; - try { - const parsed = JSON.parse(dashboard.layout); - const layoutResult = DashboardLayout.safeParse(parsed); - if (!layoutResult.success) { - throw new Response("Invalid dashboard layout", { status: 500 }); - } - existingLayout = layoutResult.data; - } catch { - throw new Response("Failed to parse dashboard layout", { status: 500 }); - } - - // 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 - await prisma.metricsDashboard.update({ - where: { id: dashboard.id }, - data: { - layout: JSON.stringify(updatedLayout), - }, - }); - - return typedjson({ success: true, deletedTitle: widgetTitle }); -}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.duplicate-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.duplicate-widget.tsx deleted file mode 100644 index 843387d4be..0000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.duplicate-widget.tsx +++ /dev/null @@ -1,123 +0,0 @@ -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 { findProjectBySlug } from "~/models/project.server"; -import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; - -const DuplicateWidgetSchema = z.object({ - widgetId: z.string().min(1, "Widget ID is required"), -}); - -const ParamsSchema = EnvironmentParamSchema.extend({ - dashboardId: z.string(), -}); - -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 }); - } - - // 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 rawData = { - widgetId: formData.get("widgetId"), - }; - - const result = DuplicateWidgetSchema.safeParse(rawData); - if (!result.success) { - throw new Response("Invalid form data: " + result.error.message, { status: 400 }); - } - - const { widgetId } = result.data; - - // Parse existing layout - let existingLayout: z.infer; - try { - const parsed = JSON.parse(dashboard.layout); - const layoutResult = DashboardLayout.safeParse(parsed); - if (!layoutResult.success) { - throw new Response("Invalid dashboard layout", { status: 500 }); - } - existingLayout = layoutResult.data; - } catch { - throw new Response("Failed to parse dashboard layout", { status: 500 }); - } - - // 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 }); - } - - // Generate new widget ID - const newWidgetId = 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 - await prisma.metricsDashboard.update({ - where: { id: dashboard.id }, - data: { - layout: JSON.stringify(updatedLayout), - }, - }); - - return typedjson({ success: true, duplicatedTitle: originalWidget.title }); -}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx deleted file mode 100644 index bce6565d23..0000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.update-widget.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { type ActionFunctionArgs } from "@remix-run/node"; -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 { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; - -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 ParamsSchema = EnvironmentParamSchema.extend({ - dashboardId: z.string(), -}); - -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 }); - } - - // 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 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; - - // Parse existing layout - let existingLayout: z.infer; - try { - const parsed = JSON.parse(dashboard.layout); - const layoutResult = DashboardLayout.safeParse(parsed); - if (!layoutResult.success) { - throw new Response("Dashboard layout is corrupt", { status: 500 }); - } - existingLayout = layoutResult.data; - } catch (e) { - if (e instanceof Response) throw e; - throw new Response("Failed to parse dashboard layout", { status: 500 }); - } - - // 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 - await prisma.metricsDashboard.update({ - where: { id: dashboard.id }, - data: { - layout: JSON.stringify(updatedLayout), - }, - }); - - return typedjson({ success: true, updatedTitle: title }); -}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx new file mode 100644 index 0000000000..38553f8ba7 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx @@ -0,0 +1,355 @@ +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 { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; + +// Schemas for each action type +const AddWidgetSchema = z.object({ + 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 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 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"), +}); + +const ParamsSchema = EnvironmentParamSchema.extend({ + dashboardId: z.string(), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, dashboardId } = ParamsSchema.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"); + + // Parse existing layout (shared across all actions) + let existingLayout: z.infer; + 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 = { + 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; + + // Generate new widget ID + const 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, reasonable height) + const newLayoutItem = { + i: widgetId, + x: 0, + y: maxBottom, + w: 12, + h: 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true, addedTitle: title }); + } + + 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true, updatedTitle: 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true, deletedTitle: widgetTitle }); + } + + case "duplicate": { + const rawData = { + widgetId: formData.get("widgetId"), + }; + + 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 }); + } + + // Generate new widget ID + const newWidgetId = 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true, duplicatedTitle: originalWidget.title }); + } + + default: { + throw new Response("Invalid action", { status: 400 }); + } + } +}; From 021137b9719e8f40ad89fb9ff5f61f1c546d27c8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 09:20:40 -0800 Subject: [PATCH 036/131] Change to a sync for metrics changes --- apps/webapp/app/hooks/useDashboardEditor.ts | 432 ++++++++++++++++++ .../route.tsx | 36 +- .../route.tsx | 240 ++++------ 3 files changed, 539 insertions(+), 169 deletions(-) create mode 100644 apps/webapp/app/hooks/useDashboardEditor.ts diff --git a/apps/webapp/app/hooks/useDashboardEditor.ts b/apps/webapp/app/hooks/useDashboardEditor.ts new file mode 100644 index 0000000000..4fe826ceab --- /dev/null +++ b/apps/webapp/app/hooks/useDashboardEditor.ts @@ -0,0 +1,432 @@ +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; + /** 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: "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 } } + | { 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 "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)); + return { + ...state, + layout: [ + ...state.layout, + { ...originalLayout, i: action.payload.newId, y: maxBottom, x: 0 }, + ], + widgets: { + ...state.widgets, + [action.payload.newId]: { + ...original, + title: `${original.title} (Copy)`, + }, + }, + }; + } + + 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; + /** Callback when a sync error occurs */ + onSyncError?: (error: Error, action: string) => void; +}; + +// ============================================================================ +// Sync Queue Types +// ============================================================================ + +type WidgetSyncTask = { + type: "widget"; + action: string; + data: Record; +}; + +type LayoutSyncTask = { + type: "layout"; + layout: LayoutItem[]; +}; + +type SyncTask = WidgetSyncTask | LayoutSyncTask; + +// ============================================================================ +// Hook +// ============================================================================ + +export function useDashboardEditor({ + initialData, + widgetActionUrl, + layoutActionUrl, + onSyncError, +}: UseDashboardEditorOptions) { + const [state, dispatch] = useReducer(dashboardReducer, { + layout: initialData.layout, + widgets: initialData.widgets, + editorMode: null, + }); + + // Refs for debouncing and tracking initialization + const layoutDebounceRef = useRef | null>(null); + const isInitializedRef = useRef(false); + const currentLayoutJsonRef = useRef(JSON.stringify(initialData.layout)); + + // Sync queue to prevent race conditions + const syncQueueRef = useRef([]); + 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) => { + 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 + // ------------------------------------------------------------------------- + + const addWidget = useCallback( + (title: string, query: string, config: QueryWidgetConfig) => { + 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) + queueWidgetSync("add", { + 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) => { + const newId = nanoid(8); + + // Update local state immediately + dispatch({ type: "DUPLICATE_WIDGET", payload: { id: widgetId, newId } }); + + // Queue sync to server (processed sequentially) + // Note: Server will generate its own ID, but our local state uses newId + // This is fine since we're optimistic - the server state will be consistent + queueWidgetSync("duplicate", { widgetId }); + }, + [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, + updateWidget, + deleteWidget, + duplicateWidget, + updateLayout, + openAddEditor, + openEditEditor, + closeEditor, + }, + }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index 56500b0bfd..297df324a3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -3,8 +3,8 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { requireUser } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { - LayoutItem, - type DashboardLayout, + type LayoutItem, + type Widget, MetricDashboardPresenter, } from "~/presenters/v3/MetricDashboardPresenter.server"; import { type LoaderFunctionArgs } from "@remix-run/node"; @@ -18,7 +18,7 @@ import { MetricWidget } from "../resources.metric"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; +import { TimeFilter } from "~/components/runs/v3/SharedFilters"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { useSearchParams } from "~/hooks/useSearchParam"; import { type WidgetData } from "~/components/metrics/QueryWidget"; @@ -57,7 +57,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { key, title, layout, defaultPeriod } = useTypedLoaderData(); + const { key, title, layout: dashboardLayout, defaultPeriod } = useTypedLoaderData(); return ( @@ -66,7 +66,13 @@ export default function Page() {
- +
@@ -74,7 +80,8 @@ export default function Page() { } export function MetricDashboard({ - data, + layout, + widgets, defaultPeriod, editable, onLayoutChange, @@ -82,7 +89,10 @@ export function MetricDashboard({ onDeleteWidget, onDuplicateWidget, }: { - data: DashboardLayout; + /** The layout items (positions/sizes) - fully controlled from parent */ + layout: LayoutItem[]; + /** The widget configurations keyed by widget ID - fully controlled from parent */ + widgets: Record; defaultPeriod: string; editable: boolean; onLayoutChange?: (layout: LayoutItem[]) => void; @@ -90,19 +100,10 @@ export function MetricDashboard({ onDeleteWidget?: (widgetId: string) => void; onDuplicateWidget?: (widgetId: string, widget: WidgetData) => void; }) { - const [layout, setLayout] = useState(data.layout); const { value } = useSearchParams(); const { width, containerRef, mounted } = useContainerWidth(); const [resizingItemId, setResizingItemId] = useState(null); - // Sync layout state when navigating to a different dashboard. - // useState only initializes once, so we need this effect to update - // the layout when the data prop changes (e.g., switching dashboards). - const dataLayoutJson = JSON.stringify(data.layout); - useEffect(() => { - setLayout(data.layout); - }, [dataLayoutJson]); - const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -117,7 +118,6 @@ export function MetricDashboard({ const handleLayoutChange = useCallback( (newLayout: readonly LayoutItem[]) => { const mutableLayout = [...newLayout]; - setLayout(mutableLayout); onLayoutChange?.(mutableLayout); }, [onLayoutChange] @@ -151,7 +151,7 @@ export function MetricDashboard({ onResizeStart={(_layout, oldItem) => setResizingItemId(oldItem?.i ?? null)} onResizeStop={() => setResizingItemId(null)} > - {Object.entries(data.widgets).map(([key, widget]) => ( + {Object.entries(widgets).map(([key, widget]) => (
{ } }; -// Editor mode state type -type EditorMode = null | { type: "add" } | { type: "edit"; widgetId: string; widget: WidgetData }; - export default function Page() { const { friendlyId, title, - layout, + layout: dashboardLayout, defaultPeriod, queryDefaultQuery, queryHistory, @@ -229,172 +225,113 @@ export default function Page() { const plan = useCurrentPlan(); const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; - const fetcher = useFetcher(); - const widgetActionFetcher = useFetcher(); - const { revalidate } = useRevalidator(); - const debounceTimeoutRef = useRef | null>(null); - const isInitializedRef = useRef(false); - const currentLayoutJsonRef = useRef(JSON.stringify(layout.layout)); + // Build the action URLs + const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`; + const layoutActionUrl = ""; // Uses form action on current route + + // Handle sync errors by showing a toast + 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", + }; - // Editor mode state - const [editorMode, setEditorMode] = useState(null); + const message = actionMessages[action] || "Failed to save changes"; - // Revalidate when widget action (delete/duplicate/add/update) completes - useEffect(() => { - if (widgetActionFetcher.state === "idle" && widgetActionFetcher.data) { - revalidate(); - // Close the editor if it was open (for add/update operations) - setEditorMode(null); - } - }, [widgetActionFetcher.state, widgetActionFetcher.data]); + toast.custom((t) => ( + + )); + }, []); + + // Use the dashboard editor hook for all state management + const { state, actions } = useDashboardEditor({ + initialData: dashboardLayout, + widgetActionUrl, + layoutActionUrl, + onSyncError: handleSyncError, + }); - // Build the query action URL + // Build the query action URL for the editor const queryActionUrl = queryPath( { slug: organization.slug }, { slug: project.slug }, { slug: environment.slug } ); - // Track when the dashboard data changes (e.g., switching dashboards) - const layoutJson = JSON.stringify(layout.layout); - useEffect(() => { - // Cancel any pending save when switching dashboards - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - debounceTimeoutRef.current = null; - } - - // Update the current layout reference and mark as not yet user-modified - currentLayoutJsonRef.current = layoutJson; - 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 (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); + // 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); } - }; - }, [layoutJson]); - - const handleLayoutChange = 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; - } - - // Clear existing timeout - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - - // Debounce auto-save by 500ms - debounceTimeoutRef.current = setTimeout(() => { - currentLayoutJsonRef.current = newLayoutJson; - fetcher.submit({ action: "layout", layout: newLayoutJson }, { method: "POST" }); - }, 500); - }, []); - - const handleEditWidget = useCallback((widgetId: string, widget: WidgetData) => { - setEditorMode({ type: "edit", widgetId, widget }); - }, []); - - // Build the action URL for all widget operations - const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`; - - const handleDeleteWidget = useCallback( - (widgetId: string) => { - widgetActionFetcher.submit( - { action: "delete", widgetId }, - { method: "POST", action: widgetActionUrl } - ); }, - [widgetActionUrl] + [state.editorMode, actions] ); - const handleDuplicateWidget = useCallback( - (widgetId: string) => { - widgetActionFetcher.submit( - { action: "duplicate", widgetId }, - { method: "POST", action: widgetActionUrl } - ); - }, - [widgetActionUrl] - ); - - const handleCloseEditor = useCallback(() => { - setEditorMode(null); - }, []); - - // Render save form for the QueryEditor + // Render save button for the QueryEditor const renderSaveForm = useCallback( (data: QueryEditorSaveData) => { - const isAdd = editorMode?.type === "add"; + const isAdd = state.editorMode?.type === "add"; return ( - - - {editorMode?.type === "edit" && ( - - )} - - - - - + ); }, - [editorMode, widgetActionUrl] + [state.editorMode, handleSave] ); // Prepare editor props when in editor mode - const editorProps = editorMode + const editorProps = state.editorMode ? (() => { const mode = - editorMode.type === "add" + state.editorMode.type === "add" ? { type: "dashboard-add" as const, dashboardId: friendlyId, dashboardName: title } : { type: "dashboard-edit" as const, dashboardId: friendlyId, dashboardName: title, - widgetId: editorMode.widgetId, - widgetName: editorMode.widget.title, + widgetId: state.editorMode.widgetId, + widgetName: state.editorMode.widget.title, }; // For edit mode, use the widget's existing values as defaults const editorDefaultQuery = - editorMode.type === "edit" ? editorMode.widget.query : queryDefaultQuery; + state.editorMode.type === "edit" ? state.editorMode.widget.query : queryDefaultQuery; const editorDefaultChartConfig = - editorMode.type === "edit" && editorMode.widget.display.type === "chart" + state.editorMode.type === "edit" && state.editorMode.widget.display.type === "chart" ? { - chartType: editorMode.widget.display.chartType, - xAxisColumn: editorMode.widget.display.xAxisColumn, - yAxisColumns: editorMode.widget.display.yAxisColumns, - groupByColumn: editorMode.widget.display.groupByColumn, - stacked: editorMode.widget.display.stacked, - sortByColumn: editorMode.widget.display.sortByColumn, - sortDirection: editorMode.widget.display.sortDirection, - aggregation: editorMode.widget.display.aggregation, + 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, } : defaultChartConfig; const editorDefaultResultsView = - editorMode.type === "edit" ? editorMode.widget.display.type : "table"; + state.editorMode.type === "edit" ? state.editorMode.widget.display.type : "table"; // Pass the existing result data when editing const editorDefaultData = - editorMode.type === "edit" ? editorMode.widget.resultData : undefined; + state.editorMode.type === "edit" ? state.editorMode.widget.resultData : undefined; return { mode, @@ -414,7 +351,7 @@ export default function Page() { @@ -430,19 +367,20 @@ export default function Page() {
{/* Query Editor Sheet - opens on top of the dashboard */} - !open && setEditorMode(null)}> + !open && actions.closeEditor()}> )} From de4a52eb04d914db6ca39839b55bbf5d51cd41de Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 09:28:03 -0800 Subject: [PATCH 037/131] Reload widget when the query changes --- apps/webapp/app/routes/resources.metric.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index ca426f9457..5fa8305620 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -173,10 +173,10 @@ export function MetricWidget({ // Reload periodically and on focus useInterval({ interval: refreshIntervalMs, callback: submit }); - // If the time period changes, reload + // Reload when query or time period changes useEffect(() => { submit(); - }, [props.from, props.to, props.period]); + }, [props.query, props.from, props.to, props.period]); const data = fetcher.data?.success ? { rows: fetcher.data.data.rows, columns: fetcher.data.data.columns } From 0042ae2de8caaf48af273887c1aab6f35f3ee931 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 09:35:32 -0800 Subject: [PATCH 038/131] Fix for duplicated widgets updating improperly --- apps/webapp/app/hooks/useDashboardEditor.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/hooks/useDashboardEditor.ts b/apps/webapp/app/hooks/useDashboardEditor.ts index 4fe826ceab..0a9ffd7d5f 100644 --- a/apps/webapp/app/hooks/useDashboardEditor.ts +++ b/apps/webapp/app/hooks/useDashboardEditor.ts @@ -82,6 +82,15 @@ function dashboardReducer(state: DashboardState, action: DashboardAction): Dashb 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: [ @@ -90,10 +99,7 @@ function dashboardReducer(state: DashboardState, action: DashboardAction): Dashb ], widgets: { ...state.widgets, - [action.payload.newId]: { - ...original, - title: `${original.title} (Copy)`, - }, + [action.payload.newId]: duplicatedWidget, }, }; } From 4663a576182f4923676e1c01789b9fd8c706b62a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 10:02:58 -0800 Subject: [PATCH 039/131] Renaming widgets --- .../app/components/metrics/QueryWidget.tsx | 65 ++++++++- .../metrics/SaveToDashboardDialog.tsx | 3 +- .../app/components/query/QueryEditor.tsx | 128 ++++++++++++++++-- apps/webapp/app/hooks/useDashboardEditor.ts | 28 ++++ .../route.tsx | 5 + .../route.tsx | 1 + apps/webapp/app/routes/resources.metric.tsx | 5 + ...vParam.dashboards.$dashboardId.widgets.tsx | 67 ++++++++- 8 files changed, 284 insertions(+), 18 deletions(-) diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index 361a027b6b..dfff0fa1f8 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -5,11 +5,12 @@ import { z } from "zod"; import { assertNever } from "assert-never"; import { TSQLResultsTable } from "../code/TSQLResultsTable"; import { QueryResultsChart } from "../code/QueryResultsChart"; -import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog"; +import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog"; import { Button } from "../primitives/Buttons"; import { ArrowsPointingOutIcon, DocumentDuplicateIcon, + PencilIcon, PencilSquareIcon, TrashIcon, } from "@heroicons/react/20/solid"; @@ -23,6 +24,10 @@ import { PopoverMenuItem, PopoverVerticalEllipseTrigger, } from "../primitives/Popover"; +import { Input } from "../primitives/Input"; +import { InputGroup } from "../primitives/InputGroup"; +import { Label } from "../primitives/Label"; +import { DialogClose } from "@radix-ui/react-dialog"; const ChartType = z.union([z.literal("bar"), z.literal("line")]); export type ChartType = z.infer; @@ -91,6 +96,8 @@ export type WidgetData = { export type QueryWidgetProps = { title: ReactNode; + /** String title for rename dialog (optional - if not provided, rename won't be available) */ + titleString?: string; isLoading?: boolean; error?: string; data: QueryWidgetData; @@ -100,6 +107,8 @@ export type QueryWidgetProps = { 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. */ @@ -108,20 +117,24 @@ export type QueryWidgetProps = { export function QueryWidget({ title, + titleString, 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 hasMenu = onEdit || onDelete || onDuplicate; + const hasMenu = onEdit || onRename || onDelete || onDuplicate; return (
@@ -149,6 +162,17 @@ export function QueryWidget({ }} /> )} + {onRename && ( + { + setRenameValue(titleString ?? ""); + setIsRenameDialogOpen(true); + setIsMenuOpen(false); + }} + /> + )} {onDuplicate && ( + + {/* Rename Dialog */} + {onRename && ( + + + Rename chart +
{ + e.preventDefault(); + if (renameValue.trim()) { + onRename(renameValue.trim()); + setIsRenameDialogOpen(false); + } + }} + > + + + setRenameValue(e.target.value)} + placeholder="Chart title" + autoFocus + /> + + + + + + + +
+
+
+ )}
); } diff --git a/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx b/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx index 41086e30b3..7613dfa651 100644 --- a/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx +++ b/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx @@ -40,7 +40,7 @@ export function SaveToDashboardDialog({ // Build the form action URL const formAction = selectedDashboardId - ? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/add-widget` + ? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/widgets` : ""; const isLoading = navigation.formAction === formAction && navigation.state === "submitting"; @@ -86,6 +86,7 @@ export function SaveToDashboardDialog({ Save to Dashboard
+ diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 2a7ea79fa9..909fb56142 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -2,6 +2,7 @@ import { ArrowDownTrayIcon, BookmarkIcon, ClipboardIcon, + PencilIcon, XMarkIcon, } from "@heroicons/react/20/solid"; import type { OutputColumnMetadata } from "@internal/clickhouse"; @@ -71,6 +72,11 @@ import { } from "~/components/metrics/QueryWidget"; import { SaveToDashboardDialog } from "~/components/metrics/SaveToDashboardDialog"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Dialog, DialogContent, DialogFooter, DialogHeader } from "~/components/primitives/Dialog"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; /** Convert a Date or ISO string to ISO string format */ function toISOString(value: Date | string): string { @@ -409,16 +415,21 @@ export function QueryEditor({ const initialTitle = mode.type === "dashboard-edit" ? mode.widgetName : null; const [editModeTitle, setEditModeTitle] = useState(initialTitle); - // Effective title: edit mode title > history title > generated title - const queryTitle = - mode.type === "dashboard-edit" + // User-set title (takes priority, and disables AI regeneration) + const [userTitle, setUserTitle] = useState(null); + + // Effective title: user title > edit mode title > history title > generated title + const queryTitle = userTitle ?? (mode.type === "dashboard-edit" ? editModeTitle ?? historyTitle ?? generatedTitle ?? null - : historyTitle ?? generatedTitle ?? null; + : historyTitle ?? generatedTitle ?? null); + + // Track if user has manually set a title (disables AI regeneration) + const hasUserTitle = userTitle !== null; // Track whether we should generate a title for the current results const [shouldGenerateTitle, setShouldGenerateTitle] = useState(false); - // Trigger title generation when query succeeds (only for new queries, not history) + // Trigger title generation when query succeeds (only for new queries, not history, not if user set title) useEffect(() => { if ( results?.rows && @@ -426,6 +437,7 @@ export function QueryEditor({ results.queryId && shouldGenerateTitle && !historyTitle && + !hasUserTitle && titleFetcher.state === "idle" ) { const currentQuery = editorRef.current?.getQuery(); @@ -445,6 +457,7 @@ export function QueryEditor({ results, shouldGenerateTitle, historyTitle, + hasUserTitle, titleFetcher, organization.slug, project.slug, @@ -491,16 +504,27 @@ export function QueryEditor({ const handleQuerySubmit = useCallback(() => { setHistoryTitle(null); // Clear history title when running a new query setEditModeTitle(null); // Clear edit mode title when running a new query - setShouldGenerateTitle(true); // Enable title generation for new results - }, []); + // Only enable title generation if user hasn't manually set a title + // userTitle persists across query edits once set + setShouldGenerateTitle(!hasUserTitle); + }, [hasUserTitle]); // Handle history selection - use existing title if available const handleHistorySelected = useCallback((item: QueryHistoryItem) => { setHistoryTitle(item.title ?? null); setEditModeTitle(null); + setUserTitle(null); // Clear user title when selecting from history setShouldGenerateTitle(false); // Don't generate title for history items }, []); + // Handle user renaming the title + const handleRenameTitle = useCallback((newTitle: string) => { + setUserTitle(newTitle); + // Clear other title sources since user has explicitly set the title + setHistoryTitle(null); + setEditModeTitle(null); + }, []); + // Compute current save data for the save render prop const currentQuery = editorRef.current?.getQuery() ?? ""; const saveData: QueryEditorSaveData = { @@ -697,7 +721,11 @@ export function QueryEditor({
+ } data={{ rows: results.rows, @@ -754,6 +782,7 @@ export function QueryEditor({ onChartConfigChange={handleChartConfigChange} queryTitle={queryTitle} isTitleLoading={isTitleLoading} + onRenameTitle={handleRenameTitle} accessory={ mode.type === "standalone" ? ( void; +}) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [renameValue, setRenameValue] = useState(title ?? ""); + + // Update rename value when title changes + useEffect(() => { + setRenameValue(title ?? ""); + }, [title]); + if (isTitleLoading) return ( @@ -835,7 +880,60 @@ function QueryTitle({ isTitleLoading, title }: { isTitleLoading: boolean; title: ); - return title ?? "Results"; + return ( + <> + + {title ?? "Results"} + {onRename && title && ( + + )} + + {onRename && ( + + + Rename chart + { + e.preventDefault(); + if (renameValue.trim()) { + onRename(renameValue.trim()); + setIsDialogOpen(false); + } + }} + > + + + setRenameValue(e.target.value)} + placeholder="Chart title" + autoFocus + /> + + + + + + + + + + + )} + + ); } function ExportResultsButton({ @@ -973,6 +1071,7 @@ function ResultsChart({ onChartConfigChange, queryTitle, isTitleLoading, + onRenameTitle, accessory, }: { rows: Record[]; @@ -981,6 +1080,7 @@ function ResultsChart({ onChartConfigChange: (config: ChartConfiguration) => void; queryTitle: string | null; isTitleLoading: boolean; + onRenameTitle?: (newTitle: string) => void; accessory?: ReactNode; }) { return ( @@ -989,7 +1089,13 @@ function ResultsChart({
} + title={ + + } data={{ rows, columns, diff --git a/apps/webapp/app/hooks/useDashboardEditor.ts b/apps/webapp/app/hooks/useDashboardEditor.ts index 0a9ffd7d5f..e61f5d0f19 100644 --- a/apps/webapp/app/hooks/useDashboardEditor.ts +++ b/apps/webapp/app/hooks/useDashboardEditor.ts @@ -32,6 +32,7 @@ type DashboardState = { 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[] } } @@ -67,6 +68,21 @@ function dashboardReducer(state: DashboardState, action: DashboardAction): Dashb 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 { @@ -371,6 +387,17 @@ export function useDashboardEditor({ [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) @@ -427,6 +454,7 @@ export function useDashboardEditor({ actions: { addWidget, updateWidget, + renameWidget, deleteWidget, duplicateWidget, updateLayout, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index 297df324a3..a918d2c4d5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -86,6 +86,7 @@ export function MetricDashboard({ editable, onLayoutChange, onEditWidget, + onRenameWidget, onDeleteWidget, onDuplicateWidget, }: { @@ -97,6 +98,7 @@ export function MetricDashboard({ editable: boolean; 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; }) { @@ -173,6 +175,9 @@ export function MetricDashboard({ ? (resultData) => onEditWidget(key, { ...widget, resultData }) : undefined } + onRename={ + onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined + } onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined} onDuplicate={ onDuplicateWidget diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 9b888dcef2..bc5b1cd921 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -373,6 +373,7 @@ export default function Page() { editable={true} onLayoutChange={actions.updateLayout} onEditWidget={actions.openEditEditor} + onRenameWidget={actions.renameWidget} onDeleteWidget={actions.deleteWidget} onDuplicateWidget={actions.duplicateWidget} /> diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index 5fa8305620..a45d2631d2 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -141,6 +141,8 @@ type MetricWidgetProps = { 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 */ @@ -155,6 +157,7 @@ export function MetricWidget({ isResizing, isDraggable, onEdit, + onRename, onDelete, onDuplicate, ...props @@ -185,6 +188,7 @@ export function MetricWidget({ return ( diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx index 38553f8ba7..21b743f8db 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx @@ -1,13 +1,14 @@ -import { type ActionFunctionArgs } from "@remix-run/node"; +import { type ActionFunctionArgs, redirect } 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 } from "~/presenters/v3/MetricDashboardPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; // Schemas for each action type const AddWidgetSchema = z.object({ @@ -61,6 +62,11 @@ const UpdateWidgetSchema = z.object({ }), }); +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"), }); @@ -75,13 +81,18 @@ const ParamsSchema = EnvironmentParamSchema.extend({ export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam, dashboardId } = ParamsSchema.parse(params); + 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: { @@ -190,7 +201,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); - return typedjson({ success: true, addedTitle: title }); + // Redirect to the dashboard + const dashboardPath = v3CustomDashboardPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: dashboardId } + ); + return redirect(dashboardPath); } case "update": { @@ -240,6 +258,47 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true, renamedTitle: title }); + } + case "delete": { const rawData = { widgetId: formData.get("widgetId"), From fc03d07fd6d9fa4b67938617219e96b297d6a0f3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 10:34:47 -0800 Subject: [PATCH 040/131] Added filters to metrics --- .../app/components/metrics/QueuesFilter.tsx | 212 ++++++++++++++++++ .../app/components/metrics/ScopeFilter.tsx | 64 ++++++ .../route.tsx | 51 ++++- .../route.tsx | 39 ++-- apps/webapp/app/routes/resources.metric.tsx | 14 +- .../app/services/queryService.server.ts | 12 + internal-packages/tsql/src/index.ts | 3 +- internal-packages/tsql/src/query/printer.ts | 15 ++ .../tsql/src/query/printer_context.ts | 15 +- 9 files changed, 393 insertions(+), 32 deletions(-) create mode 100644 apps/webapp/app/components/metrics/QueuesFilter.tsx create mode 100644 apps/webapp/app/components/metrics/ScopeFilter.tsx diff --git a/apps/webapp/app/components/metrics/QueuesFilter.tsx b/apps/webapp/app/components/metrics/QueuesFilter.tsx new file mode 100644 index 0000000000..20bffeeb55 --- /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/ScopeFilter.tsx b/apps/webapp/app/components/metrics/ScopeFilter.tsx new file mode 100644 index 0000000000..1bf6b68567 --- /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/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index a918d2c4d5..f46a00188a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -1,5 +1,8 @@ +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 { @@ -19,9 +22,13 @@ 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 type { QueryScope } from "~/services/queryService.server"; const ParamSchema = EnvironmentParamSchema.extend({ dashboardKey: z.string(), @@ -48,16 +55,30 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } const presenter = new MetricDashboardPresenter(); - const dashboard = await presenter.builtInDashboard({ - organizationId: project.organizationId, - key: dashboardKey, - }); + const [dashboard, possibleTasks] = await Promise.all([ + presenter.builtInDashboard({ + organizationId: project.organizationId, + key: dashboardKey, + }), + getAllTaskIdentifiers($replica, environment.id), + ]); - return typedjson(dashboard); + 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 } = useTypedLoaderData(); + const { + key, + title, + layout: dashboardLayout, + defaultPeriod, + possibleTasks, + } = useTypedLoaderData(); return ( @@ -72,6 +93,7 @@ export default function Page() { widgets={dashboardLayout.widgets} defaultPeriod={defaultPeriod} editable={false} + possibleTasks={possibleTasks} />
@@ -84,6 +106,7 @@ export function MetricDashboard({ widgets, defaultPeriod, editable, + possibleTasks, onLayoutChange, onEditWidget, onRenameWidget, @@ -96,13 +119,15 @@ export function MetricDashboard({ widgets: Record; 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 } = useSearchParams(); + const { value, values } = useSearchParams(); const { width, containerRef, mounted } = useContainerWidth(); const [resizingItemId, setResizingItemId] = useState(null); @@ -116,6 +141,9 @@ export function MetricDashboard({ const period = value("period"); const from = value("from"); const to = value("to"); + const scope = (value("scope") as QueryScope) ?? "environment"; + const tasks = values("tasks").filter((v) => v !== ""); + const queues = values("queues").filter((v) => v !== ""); const handleLayoutChange = useCallback( (newLayout: readonly LayoutItem[]) => { @@ -127,7 +155,10 @@ export function MetricDashboard({ return (
-
+
+ + + 0 ? tasks : undefined} + queues={queues.length > 0 ? queues : undefined} config={widget.display} organizationId={organization.id} projectId={project.id} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index bc5b1cd921..9c6f601526 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -30,7 +30,7 @@ import { import { Sheet, SheetContent } from "~/components/primitives/SheetV3"; import { ToastUI } from "~/components/primitives/Toast"; import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { env } from "~/env.server"; import { useDashboardEditor } from "~/hooks/useDashboardEditor"; import { useEnvironment } from "~/hooks/useEnvironment"; @@ -39,7 +39,11 @@ import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { LayoutItem, MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server"; +import { getAllTaskIdentifiers } from "~/models/task.server"; +import { + LayoutItem, + MetricDashboardPresenter, +} from "~/presenters/v3/MetricDashboardPresenter.server"; import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, queryPath, v3BuiltInDashboardPath } from "~/utils/pathBuilder"; @@ -72,16 +76,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } const dashboardPresenter = new MetricDashboardPresenter(); - const dashboard = await dashboardPresenter.customDashboard({ - friendlyId: dashboardId, - organizationId: project.organizationId, - }); - - // Load query-related data for the editor const queryPresenter = new QueryPresenter(); - const { defaultQuery, history } = await queryPresenter.call({ - organizationId: project.organizationId, - }); + + 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; @@ -93,6 +99,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { 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)), }); }; @@ -217,6 +226,7 @@ export default function Page() { queryHistory, isAdmin, maxRows, + possibleTasks, } = useTypedLoaderData(); const organization = useOrganization(); @@ -348,11 +358,7 @@ export default function Page() { } /> - @@ -371,6 +377,7 @@ export default function Page() { widgets={state.widgets} defaultPeriod={defaultPeriod} editable={true} + possibleTasks={possibleTasks} onLayoutChange={actions.updateLayout} onEditWidget={actions.openEditEditor} onRenameWidget={actions.renameWidget} diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index a45d2631d2..b8bcfc40de 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -103,6 +103,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { period, from, to, + taskIdentifiers, + queues, // Set higher concurrency if many widgets are on screen at once customOrgConcurrencyLimit: 15, }); @@ -176,10 +178,18 @@ export function MetricWidget({ // Reload periodically and on focus useInterval({ interval: refreshIntervalMs, callback: submit }); - // Reload when query or time period changes + // Reload when query, time period, or filters change useEffect(() => { submit(); - }, [props.query, props.from, props.to, props.period]); + }, [ + props.query, + props.from, + props.to, + props.period, + props.scope, + JSON.stringify(props.taskIdentifiers), + JSON.stringify(props.queues), + ]); const data = fetcher.data?.success ? { rows: fetcher.data.data.rows, columns: fetcher.data.data.columns } diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts index a6d9c7cd37..823e3ccdd5 100644 --- a/apps/webapp/app/services/queryService.server.ts +++ b/apps/webapp/app/services/queryService.server.ts @@ -70,6 +70,10 @@ export type ExecuteQueryOptions = Omit< period?: string | null; from?: string | null; to?: string | null; + /** Filter to specific task identifiers */ + taskIdentifiers?: string[]; + /** Filter to specific queues */ + queues?: string[]; /** History options for saving query to billing/audit */ history?: { /** Where the query originated from */ @@ -121,6 +125,8 @@ export async function executeQuery( organizationId, projectId, environmentId, + taskIdentifiers, + queues, history, customOrgConcurrencyLimit, ...baseOptions @@ -191,6 +197,12 @@ export async function executeQuery( scope === "project" || scope === "environment" ? { op: "eq", value: projectId } : undefined, environment_id: scope === "environment" ? { op: "eq", value: environmentId } : undefined, triggered_at: { op: "gte", value: maxQueryPeriodDate }, + // Optional filters for tasks and queues + task_identifier: + taskIdentifiers && taskIdentifiers.length > 0 + ? { op: "in", values: taskIdentifiers } + : undefined, + queue: queues && queues.length > 0 ? { op: "in", values: queues } : undefined, } satisfies Record; try { diff --git a/internal-packages/tsql/src/index.ts b/internal-packages/tsql/src/index.ts index e0f061eef9..7959965ace 100644 --- a/internal-packages/tsql/src/index.ts +++ b/internal-packages/tsql/src/index.ts @@ -120,6 +120,7 @@ export { DEFAULT_QUERY_SETTINGS, PrinterContext, type BetweenCondition, + type InCondition, type PrinterContextOptions, type QueryNotice, type QuerySettings, @@ -443,7 +444,6 @@ export function injectFallbackConditions( }; } - /** * Options for compiling a TSQL query to ClickHouse SQL */ @@ -547,7 +547,6 @@ export function compileTSQL(query: string, options: CompileTSQLOptions): PrintRe // 3. Create schema registry from table schemas const schemaRegistry = createSchemaRegistry(options.tableSchema); - // 4. Strip undefined values from enforcedWhereClause const enforcedWhereClause = Object.fromEntries( Object.entries(options.enforcedWhereClause).filter(([_, value]) => value !== undefined) diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 789f1836ec..963a2fff9c 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -1691,6 +1691,21 @@ export class ClickHousePrinter { return betweenExpr; } + if (condition.op === "in") { + // Create a tuple of values for the IN clause + const tupleExpr: Tuple = { + expression_type: "tuple", + exprs: condition.values.map((value) => this.createValueExpression(value)), + }; + const inExpr: CompareOperation = { + expression_type: "compare_operation", + left: fieldExpr, + right: tupleExpr, + op: CompareOperationOp.In, + }; + return inExpr; + } + // Simple comparison const compareExpr: CompareOperation = { expression_type: "compare_operation", diff --git a/internal-packages/tsql/src/query/printer_context.ts b/internal-packages/tsql/src/query/printer_context.ts index 2956e660d0..d575789f63 100644 --- a/internal-packages/tsql/src/query/printer_context.ts +++ b/internal-packages/tsql/src/query/printer_context.ts @@ -41,10 +41,20 @@ export interface BetweenCondition { } /** - * A WHERE clause condition that can be either a simple comparison or a BETWEEN. + * An IN condition (e.g., column IN ('a', 'b', 'c')) + */ +export interface InCondition { + /** The in operator */ + op: "in"; + /** The values to check against */ + values: (string | number)[]; +} + +/** + * A WHERE clause condition that can be either a simple comparison, a BETWEEN, or an IN. * Used for both enforcedWhereClause (always applied) and whereClauseFallback (default when user doesn't filter). */ -export type WhereClauseCondition = SimpleComparisonCondition | BetweenCondition; +export type WhereClauseCondition = SimpleComparisonCondition | BetweenCondition | InCondition; /** * Default query settings @@ -254,4 +264,3 @@ export function createPrinterContext(options: PrinterContextOptions): PrinterCon options.enforcedWhereClause ); } - From 9846f91e630195377cc9b8be5770598bb7b67ccd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 10:38:49 -0800 Subject: [PATCH 041/131] Side menu: Metrics -> Insights --- apps/webapp/app/components/navigation/SideMenu.tsx | 4 ++-- apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index e05af86bb1..a40f42582a 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -465,7 +465,7 @@ export function SideMenu({ {(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && ( Date: Thu, 5 Feb 2026 13:06:07 -0800 Subject: [PATCH 042/131] Fixed wiget popover width --- .../app/components/metrics/QueryWidget.tsx | 93 ++++++++++--------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index dfff0fa1f8..db2524b165 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -151,50 +151,55 @@ export function QueryWidget({ {hasMenu && ( - - {onEdit && ( - { - onEdit(props.data); - setIsMenuOpen(false); - }} - /> - )} - {onRename && ( - { - setRenameValue(titleString ?? ""); - setIsRenameDialogOpen(true); - setIsMenuOpen(false); - }} - /> - )} - {onDuplicate && ( - { - onDuplicate(props.data); - setIsMenuOpen(false); - }} - /> - )} - {onDelete && ( - { - onDelete(); - setIsMenuOpen(false); - }} - /> - )} + +
+ {onEdit && ( + { + onEdit(props.data); + setIsMenuOpen(false); + }} + className="w-fit" + /> + )} + {onRename && ( + { + setRenameValue(titleString ?? ""); + setIsRenameDialogOpen(true); + setIsMenuOpen(false); + }} + className="w-fit" + /> + )} + {onDuplicate && ( + { + onDuplicate(props.data); + setIsMenuOpen(false); + }} + className="w-fit" + /> + )} + {onDelete && ( + { + onDelete(); + setIsMenuOpen(false); + }} + /> + )} +
)} From c594488fe0f6f9ae32f94098d6872899f5e9f179 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 13:41:54 -0800 Subject: [PATCH 043/131] Fix TSQL fallback expression missing "in" operator support --- internal-packages/tsql/src/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal-packages/tsql/src/index.ts b/internal-packages/tsql/src/index.ts index 7959965ace..09dc1d549f 100644 --- a/internal-packages/tsql/src/index.ts +++ b/internal-packages/tsql/src/index.ts @@ -18,6 +18,7 @@ import type { Or, SelectQuery, SelectSetQuery, + Tuple, } from "./query/ast.js"; import { CompareOperationOp } from "./query/ast.js"; import { SyntaxError as TSQLSyntaxError } from "./query/errors.js"; @@ -357,6 +358,21 @@ export function createFallbackExpression( return betweenExpr; } + if (fallback.op === "in") { + // Create a tuple of values for the IN clause + const tupleExpr: Tuple = { + expression_type: "tuple", + exprs: fallback.values.map((value) => createValueExpression(value)), + }; + const inExpr: CompareOperation = { + expression_type: "compare_operation", + left: fieldExpr, + right: tupleExpr, + op: CompareOperationOp.In, + }; + return inExpr; + } + // Simple comparison const compareExpr: CompareOperation = { expression_type: "compare_operation", From 0efe7c6cef17ddbe8b26213569e7d2f99ca25553 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 13:49:03 -0800 Subject: [PATCH 044/131] @trigger.dev/platform@1.0.23 --- apps/webapp/package.json | 2 +- pnpm-lock.yaml | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index f95b4a96a8..661795ba08 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -121,7 +121,7 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.0.22", + "@trigger.dev/platform": "1.0.23", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f40695819..f504496dd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -498,8 +498,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.22 - version: 1.0.22 + specifier: 1.0.23 + version: 1.0.23 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -1101,7 +1101,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -10494,8 +10494,8 @@ packages: react: ^18.2.0 react-dom: 18.2.0 - '@trigger.dev/platform@1.0.22': - resolution: {integrity: sha512-tvPf40wqEDcQCZsHt/9A+WoQ08z+uObSWQ+oahqCgp3dSgKOUH8NdzZ/2ISSRiCkN2jURixNiUyDJmgsZipExg==} + '@trigger.dev/platform@1.0.23': + resolution: {integrity: sha512-/fHMOKHdqRv6t70h0weUorOeVOkX+8WGWwPlzdq+uGDqkf8ZrcwBDuBSyoG9KkyvIsA8Tw64zVbWK94CbVlznw==} '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} @@ -30668,7 +30668,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@trigger.dev/platform@1.0.22': + '@trigger.dev/platform@1.0.23': dependencies: zod: 3.23.8 @@ -39164,7 +39164,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39201,8 +39201,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3(bufferutil@4.0.9) - socket.io-client: 4.7.3(bufferutil@4.0.9) + socket.io: 4.7.3 + socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -40402,7 +40402,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3(bufferutil@4.0.9): + socket.io-client@4.7.3: dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40431,7 +40431,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3(bufferutil@4.0.9): + socket.io@4.7.3: dependencies: accepts: 1.3.8 base64id: 2.0.0 From 13be30105e53f652bee950f07ade6e1185e476a1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 13:44:49 -0800 Subject: [PATCH 045/131] Brighter + button to add a dashboard --- apps/webapp/app/components/navigation/SideMenu.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index a40f42582a..38bd22eb05 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1022,7 +1022,7 @@ function CreateDashboardButton({ @@ -1163,10 +1163,12 @@ function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onTog return (
{/* Vertical line to mask the side menu border */} -
+
From eb65ecc1d6b4cf34b558edce02df7bcc46484f83 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 13:57:50 -0800 Subject: [PATCH 046/131] Differentiated icon colors for the insights section --- apps/webapp/app/components/navigation/SideMenu.tsx | 10 +++++----- apps/webapp/tailwind.config.js | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 38bd22eb05..bf6734aaa2 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -485,8 +485,8 @@ export function SideMenu({ - Create Dashboard + + Create dashboard
@@ -1066,7 +1066,7 @@ function CreateDashboardDialog({ formAction }: { formAction: string }) { } cancelButton={ - + } /> diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index d7ee335694..8ab908adb2 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -159,6 +159,7 @@ const runs = colors.indigo[500]; const batches = colors.pink[500]; const schedules = colors.yellow[500]; const queues = colors.purple[500]; +const query = colors.blue[500]; const deployments = colors.green[500]; const concurrency = colors.amber[500]; const limits = colors.purple[500]; @@ -240,6 +241,7 @@ module.exports = { schedules, concurrency, queues, + query, regions, limits, deployments, From 377aa5d878a4dd44f9f51bae15fcca174f82ab78 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 14:22:56 -0800 Subject: [PATCH 047/131] Nice left padding --- .../route.tsx | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 9c6f601526..07ccb1052f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -443,20 +443,14 @@ function RenameDashboardDialog({ title }: { title: string }) { return ( - - {title} - - - - - + + + + Rename dashboard - + From 6f950606eae360bdf936c913270aca03c7129d3f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 14:23:10 -0800 Subject: [PATCH 048/131] Nicer padding --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index f46a00188a..7f00f22d4b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -155,7 +155,7 @@ export function MetricDashboard({ return (
-
+
From e7c7587fac293326318bc781b56d5753a04e0c67 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 14:23:38 -0800 Subject: [PATCH 049/131] Adds rename dashboard to the triple dot menu --- .../route.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 07ccb1052f..5046cb85b1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -356,14 +356,15 @@ export default function Page() { return ( - } /> + - + From c489d761ac2a63fe24e7bbede0f7f7792282b491 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 14:37:43 -0800 Subject: [PATCH 050/131] Add secondary variant style to the triple dot menu --- .../app/components/primitives/Popover.tsx | 27 ++++++++++++++++--- .../route.tsx | 17 +++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 864bdce17f..08a3d0296c 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/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 5046cb85b1..e2181335c0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -362,10 +362,12 @@ export default function Page() { Add chart - + - - +
+ + +
@@ -445,7 +447,14 @@ function RenameDashboardDialog({ title }: { title: string }) { return ( - From b8512717948f72c3efa19e6e43b98e75a329df36 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 15:04:22 -0800 Subject: [PATCH 051/131] Adds a classname to override the value in the filters --- .../app/components/primitives/AppliedFilter.tsx | 16 +++++++++++----- .../app/components/runs/v3/SharedFilters.tsx | 4 ++++ .../route.tsx | 1 + 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/primitives/AppliedFilter.tsx b/apps/webapp/app/components/primitives/AppliedFilter.tsx index f540c4f35e..a7a27f4107 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}: +
+ )}
-
+
{value}
diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index 3e7371055a..f986ab75b5 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -348,6 +348,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({ @@ -360,6 +362,7 @@ export function TimeFilter({ applyShortcut, onValueChange, maxPeriodDays, + valueClassName, }: TimeFilterProps = {}) { const { value } = useSearchParams(); const periodValue = period ?? value("period"); @@ -386,6 +389,7 @@ export function TimeFilter({ value={constrained.valueLabel} removable={false} variant="secondary/small" + valueClassName={valueClassName} /> } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index 7f00f22d4b..bf39751bab 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -164,6 +164,7 @@ export function MetricDashboard({ labelName="Period" hideLabel maxPeriodDays={maxPeriodDays} + valueClassName="text-text-bright" />
Date: Thu, 5 Feb 2026 15:04:40 -0800 Subject: [PATCH 052/131] Default wider task and queues popovers --- apps/webapp/app/components/logs/LogsTaskFilter.tsx | 4 ++-- apps/webapp/app/components/metrics/QueuesFilter.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/logs/LogsTaskFilter.tsx b/apps/webapp/app/components/logs/LogsTaskFilter.tsx index 3f95e07412..fa64eff7bd 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/QueuesFilter.tsx b/apps/webapp/app/components/metrics/QueuesFilter.tsx index 20bffeeb55..87d7a61254 100644 --- a/apps/webapp/app/components/metrics/QueuesFilter.tsx +++ b/apps/webapp/app/components/metrics/QueuesFilter.tsx @@ -40,7 +40,7 @@ export function QueuesFilter() { shortcut={shortcut} tooltipTitle="Filter by queue" > - Queues + Queues } searchValue={search} @@ -165,7 +165,7 @@ function QueuesDropdown({ {trigger} { if (onClose) { onClose(); From 304027d7a63a6fe53f8849965bbd7af2352b76fd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 15:23:36 -0800 Subject: [PATCH 053/131] Added limits --- .../metrics/SaveToDashboardDialog.tsx | 68 ++++-- .../app/components/navigation/SideMenu.tsx | 76 ++++++- apps/webapp/app/hooks/useDashboardEditor.ts | 22 +- apps/webapp/app/hooks/useOrganizations.ts | 16 ++ .../route.tsx | 196 ++++++++++++++++-- .../_app.orgs.$organizationSlug/route.tsx | 43 +++- ...vParam.dashboards.$dashboardId.widgets.tsx | 21 ++ ...tParam.env.$envParam.dashboards.create.tsx | 20 ++ 8 files changed, 416 insertions(+), 46 deletions(-) diff --git a/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx b/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx index 7613dfa651..34248926a3 100644 --- a/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx +++ b/apps/webapp/app/components/metrics/SaveToDashboardDialog.tsx @@ -3,7 +3,10 @@ import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useNavigation } from "@remix-run/react"; import { useEffect, useState } from "react"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { useCustomDashboards } from "~/hooks/useOrganizations"; +import { + useCustomDashboards, + useWidgetLimitPerDashboard, +} from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useOrganization } from "~/hooks/useOrganizations"; import { Button } from "../primitives/Buttons"; @@ -32,10 +35,14 @@ export function SaveToDashboardDialog({ const project = useProject(); const environment = useEnvironment(); const customDashboards = useCustomDashboards(); + const widgetLimit = useWidgetLimitPerDashboard(); const navigation = useNavigation(); + // Find the first dashboard that isn't at the widget limit + const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit); + const [selectedDashboardId, setSelectedDashboardId] = useState( - customDashboards.length > 0 ? customDashboards[0].friendlyId : null + firstAvailableDashboard?.friendlyId ?? (customDashboards[0]?.friendlyId ?? null) ); // Build the form action URL @@ -45,6 +52,10 @@ export function SaveToDashboardDialog({ const isLoading = navigation.formAction === formAction && navigation.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; + // Close dialog when navigation completes (redirect is happening) useEffect(() => { if (navigation.formAction === formAction && navigation.state === "loading") { @@ -55,9 +66,10 @@ export function SaveToDashboardDialog({ // Update selection if dashboards change useEffect(() => { if (customDashboards.length > 0 && !selectedDashboardId) { - setSelectedDashboardId(customDashboards[0].friendlyId); + const available = customDashboards.find((d) => d.widgetCount < widgetLimit); + setSelectedDashboardId(available?.friendlyId ?? customDashboards[0].friendlyId); } - }, [customDashboards, selectedDashboardId]); + }, [customDashboards, selectedDashboardId, widgetLimit]); if (customDashboards.length === 0) { return ( @@ -96,22 +108,36 @@ export function SaveToDashboardDialog({ Select a dashboard to add this widget to:
- {customDashboards.map((dashboard) => ( - - ))} + {customDashboards.map((dashboard) => { + const isAtLimit = dashboard.widgetCount >= widgetLimit; + return ( + + ); + })}
@@ -120,7 +146,7 @@ export function SaveToDashboardDialog({ diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index bf6734aaa2..016d5e3118 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -40,7 +40,11 @@ import { Avatar } from "~/components/primitives/Avatar"; import { type MatchedEnvironment, useEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; -import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations"; +import { + type MatchedOrganization, + useCustomDashboards, + useDashboardLimits, +} from "~/hooks/useOrganizations"; import { type MatchedProject, useProject } from "~/hooks/useProject"; import { useHasAdminAccess } from "~/hooks/useUser"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; @@ -55,6 +59,7 @@ function getSectionCollapsed( ): boolean { return sideMenu?.collapsedSections?.[sectionId] ?? false; } +import { Feedback } from "~/components/Feedback"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { type FeedbackType } from "~/routes/resources.feedback"; import { IncidentStatusPanel } from "~/routes/resources.incidents"; @@ -99,7 +104,14 @@ import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, LinkButton } from "../primitives/Buttons"; -import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog"; +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"; @@ -1002,6 +1014,14 @@ function CreateDashboardButton({ }) { 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`; @@ -1033,12 +1053,57 @@ function CreateDashboardButton({ - + {isAtLimit ? ( + + ) : ( + + )}
); } -function CreateDashboardDialog({ formAction }: { formAction: string }) { +function CreateDashboardUpgradeDialog({ + limits, + canUpgrade, + organization, +}: { + limits: { used: number; limit: number }; + canUpgrade: boolean; + organization: MatchedOrganization; +}) { + return ( + + You've exceeded your limit + + You've used {limits.used}/{limits.limit} of your custom dashboards. + + + {canUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + + + ); +} + +function CreateDashboardDialog({ + formAction, + limits, +}: { + formAction: string; + limits: { used: number; limit: number }; +}) { const navigation = useNavigation(); const [title, setTitle] = useState(""); @@ -1058,6 +1123,9 @@ function CreateDashboardDialog({ formAction }: { formAction: string }) { required /> + + {limits.used}/{limits.limit} dashboards used + diff --git a/apps/webapp/app/hooks/useDashboardEditor.ts b/apps/webapp/app/hooks/useDashboardEditor.ts index e61f5d0f19..8272207347 100644 --- a/apps/webapp/app/hooks/useDashboardEditor.ts +++ b/apps/webapp/app/hooks/useDashboardEditor.ts @@ -158,8 +158,12 @@ export type UseDashboardEditorOptions = { 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; }; // ============================================================================ @@ -187,7 +191,9 @@ export function useDashboardEditor({ initialData, widgetActionUrl, layoutActionUrl, + widgetLimit, onSyncError, + onWidgetLimitReached, }: UseDashboardEditorOptions) { const [state, dispatch] = useReducer(dashboardReducer, { layout: initialData.layout, @@ -325,6 +331,12 @@ export function useDashboardEditor({ const addWidget = useCallback( (title: string, query: string, config: QueryWidgetConfig) => { + // Guard: check widget limit + if (widgetLimit !== undefined && Object.keys(state.widgets).length >= 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 }; @@ -340,7 +352,7 @@ export function useDashboardEditor({ config: JSON.stringify(config), }); }, - [state.layout, queueWidgetSync] + [state.layout, state.widgets, widgetLimit, onWidgetLimitReached, queueWidgetSync] ); const updateWidget = useCallback( @@ -374,6 +386,12 @@ export function useDashboardEditor({ const duplicateWidget = useCallback( (widgetId: string) => { + // Guard: check widget limit + if (widgetLimit !== undefined && Object.keys(state.widgets).length >= widgetLimit) { + onWidgetLimitReached?.(); + return; + } + const newId = nanoid(8); // Update local state immediately @@ -384,7 +402,7 @@ export function useDashboardEditor({ // This is fine since we're optimistic - the server state will be consistent queueWidgetSync("duplicate", { widgetId }); }, - [queueWidgetSync] + [state.widgets, widgetLimit, onWidgetLimitReached, queueWidgetSync] ); const renameWidget = useCallback( diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts index b5777ebc51..1aa81b1104 100644 --- a/apps/webapp/app/hooks/useOrganizations.ts +++ b/apps/webapp/app/hooks/useOrganizations.ts @@ -71,3 +71,19 @@ export function useCustomDashboards(matches?: UIMatch[]) { }); return data?.customDashboards ?? []; } + +export function useDashboardLimits(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + return data?.dashboardLimits ?? { used: 0, limit: 3 }; +} + +export function useWidgetLimitPerDashboard(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + return data?.widgetLimitPerDashboard ?? 16; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index e2181335c0..3ead44851a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -1,4 +1,4 @@ -import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid"; +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"; @@ -7,15 +7,18 @@ 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 { PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTrigger, } from "~/components/primitives/Dialog"; +import { Header3 } from "~/components/primitives/Headers"; import { FormButtons } from "~/components/primitives/FormButtons"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; @@ -34,7 +37,7 @@ import { $replica, prisma } from "~/db.server"; import { env } from "~/env.server"; import { useDashboardEditor } from "~/hooks/useDashboardEditor"; import { useEnvironment } from "~/hooks/useEnvironment"; -import { useOrganization } from "~/hooks/useOrganizations"; +import { useOrganization, useWidgetLimitPerDashboard } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; @@ -46,7 +49,13 @@ import { } from "~/presenters/v3/MetricDashboardPresenter.server"; import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server"; import { requireUser, requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, queryPath, v3BuiltInDashboardPath } from "~/utils/pathBuilder"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +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 { IconEdit } from "@tabler/icons-react"; @@ -92,6 +101,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // 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 @@ -102,6 +114,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { possibleTasks: possibleTasks .map((task) => ({ slug: task.slug, triggerSource: task.triggerSource })) .sort((a, b) => a.slug.localeCompare(b.slug)), + widgetCount, }); }; @@ -227,6 +240,7 @@ export default function Page() { isAdmin, maxRows, possibleTasks, + widgetCount: initialWidgetCount, } = useTypedLoaderData(); const organization = useOrganization(); @@ -235,6 +249,12 @@ export default function Page() { 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 const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`; const layoutActionUrl = ""; // Uses form action on current route @@ -261,14 +281,29 @@ export default function Page() { )); }, []); + // 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 + const currentWidgetCount = Object.keys(state.widgets).length; + const widgetLimits = { used: currentWidgetCount, limit: widgetLimitPerDashboard }; + const widgetIsAtLimit = currentWidgetCount >= widgetLimitPerDashboard; + const widgetCanUpgrade = plan?.v3Subscription?.plan && !canExceedWidgets; + // Build the query action URL for the editor const queryActionUrl = queryPath( { slug: organization.slug }, @@ -358,9 +393,51 @@ export default function Page() { +<<<<<<< ours + {widgetIsAtLimit ? ( + + + + + + You've exceeded your widget limit + + You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard. + + + {widgetCanUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + + + + ) : ( + + )} +||||||| ancestor + +======= +>>>>>>> theirs @@ -373,20 +450,81 @@ export default function Page() { -
- +
+
+ +
+
+
+ + + + + +
+ } + content={`${Math.round((widgetLimits.used / widgetLimits.limit) * 100)}%`} + /> +
+ {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 widgets + + )} + {widgetCanUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} +
+
+
@@ -418,6 +556,28 @@ export default function Page() { )} + + {/* 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" + /> + )} + + +
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 7178e26462..6c9f088f5b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -96,6 +96,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { select: { friendlyId: true, title: true, + layout: true, }, orderBy: { createdAt: "desc" }, }), @@ -108,6 +109,41 @@ 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 as any) + ?.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 as any) + ?.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; + const widgets = layout.widgets; + if (widgets && typeof widgets === "object") { + widgetCount = Object.keys(widgets as Record).length; + } + } catch { + // ignore parse errors + } + return { + friendlyId: d.friendlyId, + title: d.title, + widgetCount, + }; + }); + return typedjson({ organizations, organization, @@ -115,7 +151,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environment, isImpersonating: !!impersonationId, currentPlan: { ...plan, v3Usage: { ...usage, hasExceededFreeTier, usagePercentage } }, - customDashboards, + customDashboards: customDashboardsWithWidgetCount, + dashboardLimits: { + used: customDashboards.length, + limit: dashboardLimit, + }, + widgetLimitPerDashboard, }); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx index 21b743f8db..685a97bba1 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx @@ -7,6 +7,7 @@ import { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { DashboardLayout } from "~/presenters/v3/MetricDashboardPresenter.server"; +import { getCurrentPlan } from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; @@ -140,8 +141,26 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } } + // Check widget limit for add/duplicate actions + async function checkWidgetLimit() { + const currentWidgetCount = Object.keys(existingLayout.widgets).length; + const plan = await getCurrentPlan(project.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 }); + } + } + switch (action) { case "add": { + await checkWidgetLimit(); + const rawData = { title: formData.get("title"), query: formData.get("query"), @@ -336,6 +355,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } case "duplicate": { + await checkWidgetLimit(); + const rawData = { widgetId: formData.get("widgetId"), }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.create.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.create.tsx index 7eeb3aebe2..1e33b3b681 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.create.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.create.tsx @@ -2,6 +2,7 @@ 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"; @@ -20,6 +21,25 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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"), From d95ce79098fbe2c2608ea225595857477aa6651f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 5 Feb 2026 15:34:33 -0800 Subject: [PATCH 054/131] Improved save layout --- .../route.tsx | 74 ++----------------- ...vParam.dashboards.$dashboardId.widgets.tsx | 54 +++++++++++++- 2 files changed, 58 insertions(+), 70 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 3ead44851a..0cafc24cad 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -43,10 +43,7 @@ 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 { - LayoutItem, - MetricDashboardPresenter, -} from "~/presenters/v3/MetricDashboardPresenter.server"; +import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server"; import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; @@ -118,29 +115,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); }; -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; - } - }), -}); - export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params); @@ -195,34 +169,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return typedjson({ success: true }); } - case "layout": { - const result = SaveLayoutSchema.safeParse({ - layout: formData.get("layout"), - }); - - if (!result.success) { - throw new Response("Invalid form data: " + result.error.message, { status: 400 }); - } - - // Parse existing layout to preserve widgets - const existingLayout = JSON.parse(dashboard.layout) as Record; - - // Update layout positions while preserving widgets - const updatedLayout = { - ...existingLayout, - layout: result.data.layout, - }; - - // Save to database - await prisma.metricsDashboard.update({ - where: { id: dashboard.id }, - data: { - layout: JSON.stringify(updatedLayout), - }, - }); - - return typedjson({ success: true }); - } default: { throw new Response("Invalid action", { status: 400 }); } @@ -255,9 +201,9 @@ export default function Page() { const canExceedWidgets = typeof planLimits === "object" && planLimits.canExceed === true; - // Build the action URLs + // 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 = ""; // Uses form action on current route + const layoutActionUrl = widgetActionUrl; // Handle sync errors by showing a toast const handleSyncError = useCallback((error: Error, action: string) => { @@ -393,11 +339,10 @@ export default function Page() { -<<<<<<< ours {widgetIsAtLimit ? ( - @@ -422,22 +367,13 @@ export default function Page() { ) : ( )} -||||||| ancestor - -======= - ->>>>>>> theirs diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx index 685a97bba1..ab9e99498c 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx @@ -6,7 +6,10 @@ 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 } from "~/presenters/v3/MetricDashboardPresenter.server"; +import { + DashboardLayout, + LayoutItem, +} from "~/presenters/v3/MetricDashboardPresenter.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; @@ -76,6 +79,29 @@ const DuplicateWidgetSchema = z.object({ widgetId: z.string().min(1, "Widget ID is required"), }); +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(), }); @@ -428,6 +454,32 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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 + await prisma.metricsDashboard.update({ + where: { id: dashboard.id }, + data: { + layout: JSON.stringify(updatedLayout), + }, + }); + + return typedjson({ success: true }); + } + default: { throw new Response("Invalid action", { status: 400 }); } From 3a4c75f044df4cf4b1c228797b7c740cd1f95bf1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 15:36:10 -0800 Subject: [PATCH 055/131] Moves the add new dashboard button to the Metrics side menu item --- .../app/components/navigation/SideMenu.tsx | 20 ++-- .../components/navigation/SideMenuItem.tsx | 110 +++++++++++------- 2 files changed, 77 insertions(+), 53 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 016d5e3118..e3357ef2ea 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -485,14 +485,6 @@ export function SideMenu({ "metrics" )} onCollapseToggle={handleSectionToggle("metrics")} - headerAction={ - - } > + } /> {customDashboards.map((dashboard) => ( diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index a89765ad44..d9f5a55266 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,82 @@ 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 && !isCollapsed) { + return ( +
+
+ ); + } + + return ( + Date: Thu, 5 Feb 2026 15:44:13 -0800 Subject: [PATCH 056/131] Removed `any` from limits --- .../app/routes/_app.orgs.$organizationSlug/route.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 6c9f088f5b..6fea018af5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -110,20 +110,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } // Derive metric dashboard limit from plan, fallback to 3 - const metricDashboardsLimitValue = (plan?.v3Subscription?.plan?.limits as any) - ?.metricDashboards; + const metricDashboardsLimitValue = plan?.v3Subscription?.plan?.limits?.metricDashboards; const dashboardLimit = typeof metricDashboardsLimitValue === "number" ? metricDashboardsLimitValue - : (metricDashboardsLimitValue?.number ?? 3); + : metricDashboardsLimitValue?.number ?? 3; // Derive widget-per-dashboard limit from plan, fallback to 16 - const metricWidgetsLimitValue = (plan?.v3Subscription?.plan?.limits as any) - ?.metricWidgetsPerDashboard; + const metricWidgetsLimitValue = plan?.v3Subscription?.plan?.limits?.metricWidgetsPerDashboard; const widgetLimitPerDashboard = typeof metricWidgetsLimitValue === "number" ? metricWidgetsLimitValue - : (metricWidgetsLimitValue?.number ?? 16); + : metricWidgetsLimitValue?.number ?? 16; // Compute widget counts per dashboard from layout JSON const customDashboardsWithWidgetCount = customDashboards.map((d) => { From cdb75368499c5da216b4945f4a46c4f91086ff11 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 5 Feb 2026 15:44:35 -0800 Subject: [PATCH 057/131] New metrics side menu icon colors --- apps/webapp/app/components/navigation/SideMenu.tsx | 11 +++++------ apps/webapp/tailwind.config.js | 4 ++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index e3357ef2ea..2a90c77c38 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -498,8 +498,8 @@ export function SideMenu({ @@ -1019,8 +1019,7 @@ function CreateDashboardButton({ const isAtLimit = limits.used >= limits.limit; const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards; - const canExceed = - typeof planLimits === "object" && planLimits.canExceed === true; + 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`; diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 8ab908adb2..cd3a90ae1d 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -160,6 +160,8 @@ const batches = colors.pink[500]; const schedules = colors.yellow[500]; const queues = colors.purple[500]; const query = colors.blue[500]; +const metrics = colors.green[500]; +const customDashboards = colors.charcoal[600]; const deployments = colors.green[500]; const concurrency = colors.amber[500]; const limits = colors.purple[500]; @@ -254,6 +256,8 @@ module.exports = { orgSettings, docs, bulkActions, + metrics, + customDashboards, }, focusStyles: { outline: "1px solid", From 69bf807570952bd00d1c90c36dc29931d1cdec12 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 13:14:25 +0000 Subject: [PATCH 058/131] Fix tailwind config error --- apps/webapp/tailwind.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index cd3a90ae1d..6f52ed88b0 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -161,7 +161,7 @@ const schedules = colors.yellow[500]; const queues = colors.purple[500]; const query = colors.blue[500]; const metrics = colors.green[500]; -const customDashboards = colors.charcoal[600]; +const customDashboards = charcoal[200]; const deployments = colors.green[500]; const concurrency = colors.amber[500]; const limits = colors.purple[500]; From 56c2cc45ed424efca8a217a08fdcc2a6b3272b58 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 13:50:22 +0000 Subject: [PATCH 059/131] Adds connector lines for the custom dashboard icons --- .../app/components/navigation/SideMenu.tsx | 88 ++++++++++++------- apps/webapp/tailwind.config.js | 2 +- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 2a90c77c38..6df8938698 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -5,7 +5,6 @@ import { BeakerIcon, BellAlertIcon, ChartBarIcon, - ChartBarSquareIcon, ChevronRightIcon, ClockIcon, Cog8ToothIcon, @@ -26,7 +25,8 @@ import { import { DialogClose } from "@radix-ui/react-dialog"; import { Form, Link, useFetcher, useNavigation } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { LineChartIcon } from "lucide-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"; @@ -36,8 +36,9 @@ import { LogsIcon } from "~/assets/icons/LogsIcon"; import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { Feedback } from "~/components/Feedback"; import { Avatar } from "~/components/primitives/Avatar"; -import { type MatchedEnvironment, useEnvironment } from "~/hooks/useEnvironment"; +import { type MatchedEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; import { @@ -45,21 +46,10 @@ import { useCustomDashboards, useDashboardLimits, } from "~/hooks/useOrganizations"; -import { type MatchedProject, useProject } from "~/hooks/useProject"; -import { useHasAdminAccess } from "~/hooks/useUser"; +import { type MatchedProject } from "~/hooks/useProject"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { ShortcutKey } from "../primitives/ShortcutKey"; +import { useHasAdminAccess } from "~/hooks/useUser"; import { type UserWithDashboardPreferences } from "~/models/user.server"; -import { type SideMenuSectionId } from "./sideMenuTypes"; - -/** 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; -} -import { Feedback } from "~/components/Feedback"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { type FeedbackType } from "~/routes/resources.feedback"; import { IncidentStatusPanel } from "~/routes/resources.incidents"; @@ -98,7 +88,7 @@ import { v3UsagePath, v3WaitpointTokensPath, } from "~/utils/pathBuilder"; -import { AlphaBadge, BetaBadge } from "../AlphaBadge"; +import { AlphaBadge } from "../AlphaBadge"; import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; @@ -118,6 +108,7 @@ import { InputGroup } from "../primitives/InputGroup"; import { Label } from "../primitives/Label"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; +import { ShortcutKey } from "../primitives/ShortcutKey"; import { TextLink } from "../primitives/TextLink"; import { SimpleTooltip, @@ -133,7 +124,15 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; -import { BarChart2Icon, LineChartIcon } from "lucide-react"; +import { type SideMenuSectionId } from "./sideMenuTypes"; + +/** 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, @@ -497,7 +496,7 @@ export function SideMenu({ /> } /> - {customDashboards.map((dashboard) => ( - - ))} + {customDashboards.map((dashboard, index) => { + const isLast = index === customDashboards.length - 1; + return ( + + ); + })} )} @@ -1224,6 +1232,26 @@ function AnimatedChevron({ ); } +// 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). +function TreeConnectorBranch({ className }: { className?: string }) { + return ( + + + + + ); +} + +function TreeConnectorEnd({ className }: { className?: string }) { + return ( + + + + + ); +} + function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) { const [isHovering, setIsHovering] = useState(false); diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 6f52ed88b0..f17eb04c15 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -161,7 +161,7 @@ const schedules = colors.yellow[500]; const queues = colors.purple[500]; const query = colors.blue[500]; const metrics = colors.green[500]; -const customDashboards = charcoal[200]; +const customDashboards = charcoal[400]; const deployments = colors.green[500]; const concurrency = colors.amber[500]; const limits = colors.purple[500]; From 42f374929a06922d629f4842d2d55df4fa5b698e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 14:00:27 +0000 Subject: [PATCH 060/131] Icon colour improvements --- apps/webapp/app/components/navigation/SideMenu.tsx | 6 +++--- apps/webapp/tailwind.config.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 6df8938698..c5f2f51d00 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -524,8 +524,8 @@ export function SideMenu({ ? TreeConnectorEnd : TreeConnectorBranch } - activeIconColor="text-customDashboards" - inactiveIconColor="text-customDashboards" + activeIconColor={isCollapsed ? "text-text-dimmed" : "text-customDashboards"} + inactiveIconColor={isCollapsed ? "text-text-dimmed" : "text-customDashboards"} to={v3CustomDashboardPath(organization, project, environment, dashboard)} isCollapsed={isCollapsed} /> @@ -1049,7 +1049,7 @@ function CreateDashboardButton({ diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index f17eb04c15..a2b6693e2e 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -161,7 +161,7 @@ const schedules = colors.yellow[500]; const queues = colors.purple[500]; const query = colors.blue[500]; const metrics = colors.green[500]; -const customDashboards = charcoal[400]; +const customDashboards = charcoal[500]; const deployments = colors.green[500]; const concurrency = colors.amber[500]; const limits = colors.purple[500]; From 5d9660451cbc5334fed9637d09b322117e5ab36e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 15:05:25 +0000 Subject: [PATCH 061/131] Reorderable custom dashboards --- .../app/components/navigation/SideMenu.tsx | 182 +++++++++++++++--- .../routes/resources.preferences.sidemenu.tsx | 26 ++- .../services/dashboardPreferences.server.ts | 43 ++++- apps/webapp/app/tailwind.css | 7 + apps/webapp/tailwind.config.js | 2 +- 5 files changed, 232 insertions(+), 28 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index c5f2f51d00..a477884904 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -25,8 +25,9 @@ import { import { DialogClose } from "@radix-ui/react-dialog"; import { Form, Link, useFetcher, useNavigation } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; -import { LineChartIcon } from "lucide-react"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { GripVerticalIcon, LineChartIcon } from "lucide-react"; +import { type Ref, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ReactGridLayout, { useContainerWidth, type Layout } from "react-grid-layout"; import simplur from "simplur"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { DropdownIcon } from "~/assets/icons/DropdownIcon"; @@ -182,6 +183,75 @@ export function SideMenu({ const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); const customDashboards = useCustomDashboards(); + const dashboardOrderFetcher = useFetcher(); + + // Dashboard reorder state + const [dashboardOrder, setDashboardOrder] = useState( + () => + user.dashboardPreferences.sideMenu?.customDashboardOrder?.[organization.id] ?? + customDashboards.map((d) => d.friendlyId) + ); + + // Sync order when organization changes (component may not remount) + useEffect(() => { + setDashboardOrder( + user.dashboardPreferences.sideMenu?.customDashboardOrder?.[organization.id] ?? + customDashboards.map((d) => d.friendlyId) + ); + }, [organization.id]); + + // Sort dashboards by stored order, new dashboards go to end + const orderedDashboards = useMemo(() => { + const orderMap = new Map(dashboardOrder.map((id, i) => [id, i])); + return [...customDashboards].sort((a, b) => { + const aIdx = orderMap.get(a.friendlyId) ?? Infinity; + const bIdx = orderMap.get(b.friendlyId) ?? Infinity; + return aIdx - bIdx; + }); + }, [customDashboards, dashboardOrder]); + + // Layout for ReactGridLayout (1-column vertical list, each item h=1 row) + const dashboardLayout = useMemo( + () => + orderedDashboards.map((d, i) => ({ + i: d.friendlyId, + x: 0, + y: i, + w: 1, + h: 1, + })), + [orderedDashboards] + ); + + // Width measurement for ReactGridLayout + const { + width: gridWidth, + containerRef: gridContainerRef, + mounted: gridMounted, + } = useContainerWidth({ initialWidth: 216 }); + + const canReorder = orderedDashboards.length >= 2 && !isCollapsed; + + // Handle drag stop - extract new order from layout y-positions + const handleDashboardDragStop = useCallback( + (layout: Layout) => { + const sorted = [...layout].sort((a, b) => a.y - b.y); + const newOrder = sorted.map((item) => item.i); + if (JSON.stringify(newOrder) === JSON.stringify(dashboardOrder)) return; + setDashboardOrder(newOrder); + // Persist immediately + if (!user.isImpersonating) { + const formData = new FormData(); + formData.append("organizationId", organization.id); + formData.append("customDashboardOrder", JSON.stringify(newOrder)); + dashboardOrderFetcher.submit(formData, { + method: "POST", + action: "/resources/preferences/sidemenu", + }); + } + }, + [dashboardOrder, organization.id, user.isImpersonating, dashboardOrderFetcher] + ); const persistSideMenuPreferences = useCallback( (data: { @@ -511,26 +581,82 @@ export function SideMenu({ /> } /> - {customDashboards.map((dashboard, index) => { - const isLast = index === customDashboards.length - 1; - return ( - - ); - })} +
}> + {canReorder && gridMounted ? ( + + {orderedDashboards.map((dashboard, index) => { + const isLast = index === orderedDashboards.length - 1; + return ( +
+ + +
+ } + /> +
+ ); + })} + + ) : ( + orderedDashboards.map((dashboard, index) => { + const isLast = index === orderedDashboards.length - 1; + return ( + + ); + }) + )} +
)} @@ -1236,7 +1362,11 @@ function AnimatedChevron({ // Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon). function TreeConnectorBranch({ className }: { className?: string }) { return ( - + @@ -1245,7 +1375,11 @@ function TreeConnectorBranch({ className }: { className?: string }) { function TreeConnectorEnd({ className }: { className?: string }) { return ( - + diff --git a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx index 8d05f574b0..a443268048 100644 --- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx +++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx @@ -4,7 +4,10 @@ import { SideMenuSectionIdSchema, type SideMenuSectionId, } from "~/components/navigation/sideMenuTypes"; -import { updateSideMenuPreferences } from "~/services/dashboardPreferences.server"; +import { + updateCustomDashboardOrder, + updateSideMenuPreferences, +} from "~/services/dashboardPreferences.server"; import { requireUser } from "~/services/session.server"; // Transforms form data string "true"/"false" to boolean, or undefined if not present @@ -17,6 +20,9 @@ const RequestSchema = z.object({ isCollapsed: booleanFromFormData, sectionId: SideMenuSectionIdSchema.optional(), sectionCollapsed: booleanFromFormData, + // Dashboard reorder fields + organizationId: z.string().optional(), + customDashboardOrder: z.string().optional(), // JSON-encoded string[] }); export async function action({ request }: ActionFunctionArgs) { @@ -30,10 +36,26 @@ export async function action({ request }: ActionFunctionArgs) { return json({ success: false, error: "Invalid request data" }, { status: 400 }); } + // Handle dashboard order update + if (result.data.organizationId && result.data.customDashboardOrder) { + const orderResult = z.array(z.string()).safeParse(JSON.parse(result.data.customDashboardOrder)); + if (orderResult.success) { + await updateCustomDashboardOrder({ + user, + organizationId: result.data.organizationId, + order: orderResult.data, + }); + } + return json({ success: true }); + } + // Build sectionCollapsed parameter if both sectionId and sectionCollapsed are provided const sectionCollapsed = result.data.sectionId !== undefined && result.data.sectionCollapsed !== undefined - ? { sectionId: result.data.sectionId as SideMenuSectionId, collapsed: result.data.sectionCollapsed } + ? { + sectionId: result.data.sectionId as SideMenuSectionId, + collapsed: result.data.sectionCollapsed, + } : undefined; await updateSideMenuPreferences({ diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 38ed6ce21a..640d4ac69b 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -7,11 +7,14 @@ const SideMenuPreferences = z.object({ isCollapsed: z.boolean().default(false), // Map for section collapsed states - keys are section identifiers collapsedSections: z.record(z.string(), z.boolean()).optional(), + // Map of organization ID -> ordered array of dashboard friendlyIds + customDashboardOrder: z.record(z.string(), z.array(z.string())).optional(), }); export type SideMenuPreferences = z.infer; -export { type SideMenuSectionId } from "~/components/navigation/sideMenuTypes"; +import { type SideMenuSectionId } from "~/components/navigation/sideMenuTypes"; +export type { SideMenuSectionId }; const DashboardPreferences = z.object({ version: z.literal("1"), @@ -167,3 +170,41 @@ export async function updateSideMenuPreferences({ }, }); } + +export async function updateCustomDashboardOrder({ + user, + organizationId, + order, +}: { + user: UserFromSession; + organizationId: string; + order: string[]; +}) { + if (user.isImpersonating) { + return; + } + + const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {}); + + const updatedSideMenu = SideMenuPreferences.parse({ + ...currentSideMenu, + customDashboardOrder: { + ...currentSideMenu.customDashboardOrder, + [organizationId]: order, + }, + }); + + const updatedPreferences: DashboardPreferences = { + ...user.dashboardPreferences, + sideMenu: updatedSideMenu, + }; + + return prisma.user.update({ + where: { + id: user.id, + }, + data: { + dashboardPreferences: updatedPreferences, + }, + }); +} diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 14e3dd2c4a..11958ee9b2 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -9,6 +9,13 @@ background: rgb(99 102 241) !important; /* indigo-500 */ } +/* Sidebar reorder grid: subtle placeholder */ +.sidebar-reorder-grid .react-grid-item.react-grid-placeholder { + background: rgb(39 42 46) !important; /* charcoal-700 */ + border-radius: 0.25rem; + opacity: 1 !important; +} + /* Override resize handle icon to white */ .react-resizable-handle { background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDYiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiNmZmZmZmYwMCIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4Ij48ZyBvcGFjaXR5PSIwLjMwMiI+PHBhdGggZD0iTSA2IDYgTCAwIDYgTCAwIDQuMiBMIDQgNC4yIEwgNC4yIDQuMiBMIDQuMiAwIEwgNiAwIEwgNiA2IEwgNiA2IFoiIGZpbGw9IiNmZmZmZmYiLz48L2c+PC9zdmc+') !important; diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index a2b6693e2e..f17eb04c15 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -161,7 +161,7 @@ const schedules = colors.yellow[500]; const queues = colors.purple[500]; const query = colors.blue[500]; const metrics = colors.green[500]; -const customDashboards = charcoal[500]; +const customDashboards = charcoal[400]; const deployments = colors.green[500]; const concurrency = colors.amber[500]; const limits = colors.purple[500]; From 15a3a32298791893c4c128b252dbdce0d16c1ced Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 15:16:11 +0000 Subject: [PATCH 062/131] Fixes the side menu collapsing animation for Metrics items --- apps/webapp/app/components/navigation/SideMenu.tsx | 6 +++--- apps/webapp/app/components/navigation/SideMenuItem.tsx | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index a477884904..37c81a2b96 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -230,7 +230,7 @@ export function SideMenu({ mounted: gridMounted, } = useContainerWidth({ initialWidth: 216 }); - const canReorder = orderedDashboards.length >= 2 && !isCollapsed; + const canReorder = orderedDashboards.length >= 2; // Handle drag stop - extract new order from layout y-positions const handleDashboardDragStop = useCallback( @@ -582,7 +582,7 @@ export function SideMenu({ } />
}> - {canReorder && gridMounted ? ( + {canReorder ? ( ); - if (action && !isCollapsed) { + if (action) { return (
-
- {action} -
+ {!isCollapsed && ( +
+ {action} +
+ )}
); } From 2ace598a76417961f7a1be0857f81e64ded7b47d Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 15:20:35 +0000 Subject: [PATCH 063/131] Smoother side menu item transition when expanding --- apps/webapp/app/tailwind.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 11958ee9b2..252efa7024 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -16,6 +16,11 @@ opacity: 1 !important; } +/* Sidebar reorder grid: only animate transform (vertical position), not width/height */ +.sidebar-reorder-grid .react-grid-item { + transition: transform 200ms ease !important; +} + /* Override resize handle icon to white */ .react-resizable-handle { background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDYiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiNmZmZmZmYwMCIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4Ij48ZyBvcGFjaXR5PSIwLjMwMiI+PHBhdGggZD0iTSA2IDYgTCAwIDYgTCAwIDQuMiBMIDQgNC4yIEwgNC4yIDQuMiBMIDQuMiAwIEwgNiAwIEwgNiA2IEwgNiA2IFoiIGZpbGw9IiNmZmZmZmYiLz48L2c+PC9zdmc+') !important; From dc25552434197eb8228d9c1b446deb33504176c1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 15:27:29 +0000 Subject: [PATCH 064/131] Update the connector type on drag --- .../app/components/navigation/SideMenu.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 37c81a2b96..4e55fe770c 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -232,9 +232,17 @@ export function SideMenu({ const canReorder = orderedDashboards.length >= 2; + // Track layout during drag for real-time tree connector updates + const [dragLayout, setDragLayout] = useState(null); + + const handleDashboardDrag = useCallback((layout: Layout) => { + setDragLayout(layout); + }, []); + // Handle drag stop - extract new order from layout y-positions const handleDashboardDragStop = 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(dashboardOrder)) return; @@ -253,6 +261,18 @@ export function SideMenu({ [dashboardOrder, organization.id, user.isImpersonating, dashboardOrderFetcher] ); + // Compute which dashboard is visually last (during drag or at rest) + const getIsLastDashboard = useCallback( + (friendlyId: string, index: number) => { + if (dragLayout) { + const maxY = Math.max(...dragLayout.map((l) => l.y)); + return dragLayout.find((l) => l.i === friendlyId)?.y === maxY; + } + return index === orderedDashboards.length - 1; + }, + [dragLayout, orderedDashboards.length] + ); + const persistSideMenuPreferences = useCallback( (data: { isCollapsed?: boolean; @@ -594,12 +614,13 @@ export function SideMenu({ }} resizeConfig={{ enabled: false }} dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }} + onDrag={handleDashboardDrag} onDragStop={handleDashboardDragStop} className="sidebar-reorder-grid" autoSize > {orderedDashboards.map((dashboard, index) => { - const isLast = index === orderedDashboards.length - 1; + const isLast = getIsLastDashboard(dashboard.friendlyId, index); return (
Date: Fri, 6 Feb 2026 17:00:36 +0000 Subject: [PATCH 065/131] Improve the upgrade dashboard modal --- .../app/components/navigation/SideMenu.tsx | 76 ++++++++++++++++--- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 4e55fe770c..360c2b8f86 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -629,8 +629,8 @@ export function SideMenu({ isCollapsed ? LineChartIcon : isLast - ? TreeConnectorEnd - : TreeConnectorBranch + ? TreeConnectorEnd + : TreeConnectorBranch } activeIconColor={isCollapsed ? "text-customDashboards" : undefined} inactiveIconColor={isCollapsed ? "text-customDashboards" : undefined} @@ -1220,6 +1220,11 @@ function CreateDashboardButton({ ); } +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, @@ -1229,20 +1234,71 @@ function CreateDashboardUpgradeDialog({ canUpgrade: boolean; organization: MatchedOrganization; }) { + const percentage = Math.min(limits.used / limits.limit, 1); + const filled = percentage * PROGRESS_RING_CIRCUMFERENCE; + return ( - You've exceeded your limit - - You've used {limits.used}/{limits.limit} of your custom dashboards. - - + Dashboard limit reached +
+
+ + + + + + {limits.limit} + +
+ + {canUpgrade ? ( + <> + You've used all {limits.limit} of your custom dashboards. Upgrade your plan to create + more. + + ) : ( + <> + 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 + + Upgrade plan ) : ( Request more} + button={} defaultValue="help" /> )} From c504c2fa7825e96c665f5efa118c29f3539e518c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 17:00:53 +0000 Subject: [PATCH 066/131] Reorder hoverstate style tweak --- apps/webapp/app/components/navigation/SideMenuItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 165c31e3af..4e6ae7e74b 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -96,7 +96,7 @@ export function SideMenuItem({ disableHoverableContent /> {!isCollapsed && ( -
+
{action}
)} From 2bc14b369740e82ea9ee4c22329d8eb2383e2dc1 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 17:26:07 +0000 Subject: [PATCH 067/131] Click anywhere on the side menu header to collapse it --- .../app/components/navigation/SideMenuSection.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 619ab6dd24..1564716512 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -40,18 +40,16 @@ export function SideMenuSection({
{/* Header - fades out when sidebar is collapsed */} -
+

{title}

Date: Fri, 6 Feb 2026 17:26:21 +0000 Subject: [PATCH 068/131] Prevent text selection when reordering side menu items --- apps/webapp/app/components/navigation/SideMenuItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 4e6ae7e74b..844782ed61 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -59,7 +59,7 @@ export function SideMenuItem({ }} transition={{ duration: 0.2, ease: "easeOut" }} > - {name} + {name} {badge && !isCollapsed && ( Date: Fri, 6 Feb 2026 17:48:32 +0000 Subject: [PATCH 069/131] Improved the delete dashboard modal --- .../route.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 0cafc24cad..af10f85df6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -198,8 +198,7 @@ export default function Page() { // Widget limits const widgetLimitPerDashboard = useWidgetLimitPerDashboard(); const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricWidgetsPerDashboard; - const canExceedWidgets = - typeof planLimits === "object" && planLimits.canExceed === true; + 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`; @@ -435,8 +434,8 @@ export default function Page() {
{widgetIsAtLimit ? ( - You've used all {widgetLimits.limit} of your available widgets. Upgrade your plan - to enable more. + You've used all {widgetLimits.limit} of your available widgets. Upgrade your + plan to enable more. ) : ( @@ -623,14 +622,14 @@ function DeleteDashboardDialog({ title }: { title: string }) { 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.
- + - - - You've exceeded your widget limit - - You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard. - - - {widgetCanUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" - /> - )} - - -
- ) : ( - - )} + {currentWidgetCount > 0 && + (widgetIsAtLimit ? ( + + + + + + You've exceeded your widget limit + + You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard. + + + {widgetCanUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + + + + ) : ( + + ))} @@ -387,19 +389,44 @@ export default function Page() {
- + {currentWidgetCount === 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. + + + + ) : ( + + )}
From 785a747c71eb6819b8d9e200292b1ec77e8286b9 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 18:19:47 +0000 Subject: [PATCH 071/131] Fix edit menu items on a widget --- apps/webapp/app/components/metrics/QueryWidget.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index db2524b165..29c5db1517 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -161,7 +161,6 @@ export function QueryWidget({ onEdit(props.data); setIsMenuOpen(false); }} - className="w-fit" /> )} {onRename && ( @@ -173,7 +172,6 @@ export function QueryWidget({ setIsRenameDialogOpen(true); setIsMenuOpen(false); }} - className="w-fit" /> )} {onDuplicate && ( @@ -184,7 +182,7 @@ export function QueryWidget({ onDuplicate(props.data); setIsMenuOpen(false); }} - className="w-fit" + className="pr-4" /> )} {onDelete && ( @@ -192,7 +190,7 @@ export function QueryWidget({ icon={TrashIcon} title="Delete chart" leadingIconClassName="text-error" - className="w-fit text-error hover:!bg-error/10" + className="text-error hover:!bg-error/10" onClick={() => { onDelete(); setIsMenuOpen(false); From a517781d661814270277ae360365e42c7f4f934a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 19:53:46 +0000 Subject: [PATCH 072/131] Consistent button text --- apps/webapp/app/components/navigation/SideMenu.tsx | 2 +- .../route.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 360c2b8f86..a3a9883dfc 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1298,7 +1298,7 @@ function CreateDashboardUpgradeDialog({ ) : ( Request increase…} + button={} defaultValue="help" /> )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 885b5f9f7c..0efd9ebd53 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -480,7 +480,7 @@ export default function Page() { ) : ( Request more} + button={} defaultValue="help" /> )} From 2014ba483cf2013c4aa0130fd24615c113f177e7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 20:00:11 +0000 Subject: [PATCH 073/131] Make sure the height of the edit query page is 100% --- apps/webapp/app/components/layout/AppLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index 0793c52cac..e0b58ed717 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({ From a6e0694988e985863c8deb98817e0e697f9390ae Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 6 Feb 2026 20:07:42 +0000 Subject: [PATCH 074/131] icon improvements --- apps/webapp/app/components/navigation/SideMenu.tsx | 7 ++++--- .../route.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index a3a9883dfc..34b709d4ad 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -24,10 +24,11 @@ import { } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, Link, useFetcher, useNavigation } from "@remix-run/react"; +import { IconChartHistogram } from "@tabler/icons-react"; import { LayoutGroup, motion } from "framer-motion"; import { GripVerticalIcon, LineChartIcon } from "lucide-react"; -import { type Ref, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ReactGridLayout, { useContainerWidth, type Layout } from "react-grid-layout"; +import { type ReactNode, type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layout"; import simplur from "simplur"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { DropdownIcon } from "~/assets/icons/DropdownIcon"; @@ -627,7 +628,7 @@ export function SideMenu({ name={dashboard.title} icon={ isCollapsed - ? LineChartIcon + ? IconChartHistogram : isLast ? TreeConnectorEnd : TreeConnectorBranch diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 0efd9ebd53..b600c8d303 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -393,7 +393,7 @@ export default function Page() { Date: Sat, 7 Feb 2026 09:15:00 +0000 Subject: [PATCH 075/131] Consistent icons --- .../route.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index b600c8d303..e6a27703d2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -2,13 +2,13 @@ import { ArrowUpCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/soli 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 { 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 { InfoPanel } from "~/components/primitives/InfoPanel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { @@ -19,8 +19,9 @@ import { DialogHeader, DialogTrigger, } from "~/components/primitives/Dialog"; -import { Header3 } from "~/components/primitives/Headers"; 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"; @@ -33,6 +34,7 @@ import { } 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"; @@ -47,7 +49,6 @@ 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 { SimpleTooltip } from "~/components/primitives/Tooltip"; import { EnvironmentParamSchema, queryPath, @@ -56,7 +57,6 @@ import { } from "~/utils/pathBuilder"; import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { IconEdit, IconLayoutDashboardFilled } from "@tabler/icons-react"; const ParamSchema = EnvironmentParamSchema.extend({ dashboardId: z.string(), @@ -392,7 +392,7 @@ export default function Page() { {currentWidgetCount === 0 ? ( Date: Sun, 8 Feb 2026 15:02:56 +0000 Subject: [PATCH 076/131] Refactored the reordering so we can reuse it --- .../navigation/DashboardDialogs.tsx | 223 +++++++++ .../components/navigation/DashboardList.tsx | 123 +++++ .../app/components/navigation/SideMenu.tsx | 430 +----------------- .../components/navigation/TreeConnectors.tsx | 29 ++ .../navigation/useReorderableList.ts | 129 ++++++ .../routes/resources.preferences.sidemenu.tsx | 16 +- .../services/dashboardPreferences.server.ts | 42 +- 7 files changed, 558 insertions(+), 434 deletions(-) create mode 100644 apps/webapp/app/components/navigation/DashboardDialogs.tsx create mode 100644 apps/webapp/app/components/navigation/DashboardList.tsx create mode 100644 apps/webapp/app/components/navigation/TreeConnectors.tsx create mode 100644 apps/webapp/app/components/navigation/useReorderableList.ts diff --git a/apps/webapp/app/components/navigation/DashboardDialogs.tsx b/apps/webapp/app/components/navigation/DashboardDialogs.tsx new file mode 100644 index 0000000000..ed422649e1 --- /dev/null +++ b/apps/webapp/app/components/navigation/DashboardDialogs.tsx @@ -0,0 +1,223 @@ +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useNavigation } from "@remix-run/react"; +import { motion } from "framer-motion"; +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 ( + + + + + + + + + + Create dashboard + + + + {isAtLimit ? ( + + ) : ( + + )} + + ); +} + +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, + organization, +}: { + limits: { used: number; limit: number }; + canUpgrade: boolean; + organization: MatchedOrganization; +}) { + const percentage = Math.min(limits.used / limits.limit, 1); + const filled = percentage * PROGRESS_RING_CIRCUMFERENCE; + + return ( + + Dashboard limit reached +
+
+ + + + + + {limits.limit} + +
+ + {canUpgrade ? ( + <> + You've used all {limits.limit} of your custom dashboards. Upgrade your plan to create + more. + + ) : ( + <> + 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 + + + + setTitle(e.target.value)} + placeholder="My Dashboard" + required + /> + + + {limits.used}/{limits.limit} dashboards used + + + {isLoading ? "Creating..." : "Create"} + + } + cancelButton={ + + + + } + /> + + + ); +} diff --git a/apps/webapp/app/components/navigation/DashboardList.tsx b/apps/webapp/app/components/navigation/DashboardList.tsx new file mode 100644 index 0000000000..132dc02689 --- /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 34b709d4ad..241bb8aea2 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -22,13 +22,9 @@ import { TableCellsIcon, UsersIcon, } from "@heroicons/react/20/solid"; -import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, Link, useFetcher, useNavigation } from "@remix-run/react"; -import { IconChartHistogram } from "@tabler/icons-react"; +import { Link, useFetcher, useNavigation } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; -import { GripVerticalIcon, LineChartIcon } from "lucide-react"; -import { type ReactNode, type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layout"; +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"; @@ -38,16 +34,11 @@ import { LogsIcon } from "~/assets/icons/LogsIcon"; import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; -import { Feedback } from "~/components/Feedback"; import { Avatar } from "~/components/primitives/Avatar"; import { type MatchedEnvironment } from "~/hooks/useEnvironment"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; -import { - type MatchedOrganization, - useCustomDashboards, - useDashboardLimits, -} from "~/hooks/useOrganizations"; +import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { type MatchedProject } from "~/hooks/useProject"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { useHasAdminAccess } from "~/hooks/useUser"; @@ -75,7 +66,6 @@ import { v3BillingPath, v3BuiltInDashboardPath, v3BulkActionsPath, - v3CustomDashboardPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, @@ -96,18 +86,7 @@ import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; import { Button, ButtonContent, 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 { Dialog, DialogTrigger } from "../primitives/Dialog"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover"; import { ShortcutKey } from "../primitives/ShortcutKey"; @@ -121,6 +100,8 @@ import { } 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"; @@ -183,96 +164,6 @@ export function SideMenu({ const isAdmin = useHasAdminAccess(); const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); - const customDashboards = useCustomDashboards(); - const dashboardOrderFetcher = useFetcher(); - - // Dashboard reorder state - const [dashboardOrder, setDashboardOrder] = useState( - () => - user.dashboardPreferences.sideMenu?.customDashboardOrder?.[organization.id] ?? - customDashboards.map((d) => d.friendlyId) - ); - - // Sync order when organization changes (component may not remount) - useEffect(() => { - setDashboardOrder( - user.dashboardPreferences.sideMenu?.customDashboardOrder?.[organization.id] ?? - customDashboards.map((d) => d.friendlyId) - ); - }, [organization.id]); - - // Sort dashboards by stored order, new dashboards go to end - const orderedDashboards = useMemo(() => { - const orderMap = new Map(dashboardOrder.map((id, i) => [id, i])); - return [...customDashboards].sort((a, b) => { - const aIdx = orderMap.get(a.friendlyId) ?? Infinity; - const bIdx = orderMap.get(b.friendlyId) ?? Infinity; - return aIdx - bIdx; - }); - }, [customDashboards, dashboardOrder]); - - // Layout for ReactGridLayout (1-column vertical list, each item h=1 row) - const dashboardLayout = useMemo( - () => - orderedDashboards.map((d, i) => ({ - i: d.friendlyId, - x: 0, - y: i, - w: 1, - h: 1, - })), - [orderedDashboards] - ); - - // Width measurement for ReactGridLayout - const { - width: gridWidth, - containerRef: gridContainerRef, - mounted: gridMounted, - } = useContainerWidth({ initialWidth: 216 }); - - const canReorder = orderedDashboards.length >= 2; - - // Track layout during drag for real-time tree connector updates - const [dragLayout, setDragLayout] = useState(null); - - const handleDashboardDrag = useCallback((layout: Layout) => { - setDragLayout(layout); - }, []); - - // Handle drag stop - extract new order from layout y-positions - const handleDashboardDragStop = 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(dashboardOrder)) return; - setDashboardOrder(newOrder); - // Persist immediately - if (!user.isImpersonating) { - const formData = new FormData(); - formData.append("organizationId", organization.id); - formData.append("customDashboardOrder", JSON.stringify(newOrder)); - dashboardOrderFetcher.submit(formData, { - method: "POST", - action: "/resources/preferences/sidemenu", - }); - } - }, - [dashboardOrder, organization.id, user.isImpersonating, dashboardOrderFetcher] - ); - - // Compute which dashboard is visually last (during drag or at rest) - const getIsLastDashboard = useCallback( - (friendlyId: string, index: number) => { - if (dragLayout) { - const maxY = Math.max(...dragLayout.map((l) => l.y)); - return dragLayout.find((l) => l.i === friendlyId)?.y === maxY; - } - return index === orderedDashboards.length - 1; - }, - [dragLayout, orderedDashboards.length] - ); const persistSideMenuPreferences = useCallback( (data: { @@ -602,83 +493,13 @@ export function SideMenu({ /> } /> -
}> - {canReorder ? ( - - {orderedDashboards.map((dashboard, index) => { - const isLast = getIsLastDashboard(dashboard.friendlyId, index); - return ( -
- - -
- } - /> -
- ); - })} - - ) : ( - orderedDashboards.map((dashboard, index) => { - const isLast = index === orderedDashboards.length - 1; - return ( - - ); - }) - )} -
+ )} @@ -1157,203 +978,6 @@ function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) { ); } -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 ( - - - - - - - - - - Create dashboard - - - - {isAtLimit ? ( - - ) : ( - - )} - - ); -} - -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, - organization, -}: { - limits: { used: number; limit: number }; - canUpgrade: boolean; - organization: MatchedOrganization; -}) { - const percentage = Math.min(limits.used / limits.limit, 1); - const filled = percentage * PROGRESS_RING_CIRCUMFERENCE; - - return ( - - Dashboard limit reached -
-
- - - - - - {limits.limit} - -
- - {canUpgrade ? ( - <> - You've used all {limits.limit} of your custom dashboards. Upgrade your plan to create - more. - - ) : ( - <> - 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 -
- - - setTitle(e.target.value)} - placeholder="My Dashboard" - required - /> - - - {limits.used}/{limits.limit} dashboards used - - - {isLoading ? "Creating..." : "Create"} - - } - cancelButton={ - - - - } - /> - -
- ); -} - function AnimatedChevron({ isHovering, isCollapsed, @@ -1436,34 +1060,6 @@ function AnimatedChevron({ ); } -// 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). -function TreeConnectorBranch({ className }: { className?: string }) { - return ( - - - - - ); -} - -function TreeConnectorEnd({ className }: { className?: string }) { - return ( - - - - - ); -} - function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) { const [isHovering, setIsHovering] = useState(false); diff --git a/apps/webapp/app/components/navigation/TreeConnectors.tsx b/apps/webapp/app/components/navigation/TreeConnectors.tsx new file mode 100644 index 0000000000..8479179a49 --- /dev/null +++ b/apps/webapp/app/components/navigation/TreeConnectors.tsx @@ -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 ( + + + + + ); +} + +export function TreeConnectorEnd({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/navigation/useReorderableList.ts b/apps/webapp/app/components/navigation/useReorderableList.ts new file mode 100644 index 0000000000..b73a054ae9 --- /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/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx index a443268048..d6f33f0a3a 100644 --- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx +++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx @@ -5,7 +5,7 @@ import { type SideMenuSectionId, } from "~/components/navigation/sideMenuTypes"; import { - updateCustomDashboardOrder, + updateItemOrder, updateSideMenuPreferences, } from "~/services/dashboardPreferences.server"; import { requireUser } from "~/services/session.server"; @@ -20,9 +20,10 @@ const RequestSchema = z.object({ isCollapsed: booleanFromFormData, sectionId: SideMenuSectionIdSchema.optional(), sectionCollapsed: booleanFromFormData, - // Dashboard reorder fields + // Generic item order fields organizationId: z.string().optional(), - customDashboardOrder: z.string().optional(), // JSON-encoded string[] + listId: z.string().optional(), + itemOrder: z.string().optional(), // JSON-encoded string[] }); export async function action({ request }: ActionFunctionArgs) { @@ -36,13 +37,14 @@ export async function action({ request }: ActionFunctionArgs) { return json({ success: false, error: "Invalid request data" }, { status: 400 }); } - // Handle dashboard order update - if (result.data.organizationId && result.data.customDashboardOrder) { - const orderResult = z.array(z.string()).safeParse(JSON.parse(result.data.customDashboardOrder)); + // Handle item order update + if (result.data.organizationId && result.data.listId && result.data.itemOrder) { + const orderResult = z.array(z.string()).safeParse(JSON.parse(result.data.itemOrder)); if (orderResult.success) { - await updateCustomDashboardOrder({ + await updateItemOrder({ user, organizationId: result.data.organizationId, + listId: result.data.listId, order: orderResult.data, }); } diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 640d4ac69b..7af007dc38 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -7,8 +7,15 @@ const SideMenuPreferences = z.object({ isCollapsed: z.boolean().default(false), // Map for section collapsed states - keys are section identifiers collapsedSections: z.record(z.string(), z.boolean()).optional(), - // Map of organization ID -> ordered array of dashboard friendlyIds - customDashboardOrder: z.record(z.string(), z.array(z.string())).optional(), + /** Organization-specific settings */ + organizations: z + .record( + z.string(), + z.object({ + orderedItems: z.record(z.string(), z.array(z.string())), + }) + ) + .optional(), }); export type SideMenuPreferences = z.infer; @@ -149,10 +156,7 @@ export async function updateSideMenuPreferences({ JSON.stringify(updatedSideMenu.collapsedSections) !== JSON.stringify(currentSideMenu.collapsedSections); - if ( - updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && - !hasCollapsedSectionsChanged - ) { + if (updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed && !hasCollapsedSectionsChanged) { return; } @@ -171,13 +175,24 @@ export async function updateSideMenuPreferences({ }); } -export async function updateCustomDashboardOrder({ +/** Get the stored item order for a specific list within an organization */ +export function getItemOrder( + sideMenu: SideMenuPreferences | undefined, + organizationId: string, + listId: string +): string[] | undefined { + return sideMenu?.organizations?.[organizationId]?.orderedItems?.[listId]; +} + +export async function updateItemOrder({ user, organizationId, + listId, order, }: { user: UserFromSession; organizationId: string; + listId: string; order: string[]; }) { if (user.isImpersonating) { @@ -185,12 +200,19 @@ export async function updateCustomDashboardOrder({ } const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {}); + const currentOrg = currentSideMenu.organizations?.[organizationId]; const updatedSideMenu = SideMenuPreferences.parse({ ...currentSideMenu, - customDashboardOrder: { - ...currentSideMenu.customDashboardOrder, - [organizationId]: order, + organizations: { + ...currentSideMenu.organizations, + [organizationId]: { + ...currentOrg, + orderedItems: { + ...currentOrg?.orderedItems, + [listId]: order, + }, + }, }, }); From f8636a76652f8164b0d1907d64e5ee2dbb6a6282 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 8 Feb 2026 15:53:38 +0000 Subject: [PATCH 077/131] WIP adding BigNumber chart type --- .../app/components/metrics/QueryWidget.tsx | 38 +++ .../app/components/primitives/ClientTabs.tsx | 3 +- .../primitives/charts/BigNumber.tsx | 46 ---- .../primitives/charts/BigNumberCard.tsx | 123 +++++++++ .../app/components/query/QueryEditor.tsx | 242 +++++++++++++++++- .../route.tsx | 15 +- .../app/routes/storybook.charts/route.tsx | 8 +- 7 files changed, 420 insertions(+), 55 deletions(-) delete mode 100644 apps/webapp/app/components/primitives/charts/BigNumber.tsx create mode 100644 apps/webapp/app/components/primitives/charts/BigNumberCard.tsx diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index 29c5db1517..d8157fef87 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -5,6 +5,7 @@ import { z } from "zod"; import { assertNever } from "assert-never"; import { TSQLResultsTable } from "../code/TSQLResultsTable"; import { QueryResultsChart } from "../code/QueryResultsChart"; +import { BigNumberCard } from "../primitives/charts/BigNumberCard"; import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog"; import { Button } from "../primitives/Buttons"; import { @@ -58,6 +59,14 @@ const chartConfigOptions = { const ChartConfiguration = z.object({ ...chartConfigOptions }); export type ChartConfiguration = z.infer; +const bigNumberConfigOptions = { + column: z.string(), + aggregation: AggregationType, +}; + +const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions }); +export type BigNumberConfiguration = z.infer; + export const QueryWidgetConfig = z.discriminatedUnion("type", [ z.object({ type: z.literal("table"), @@ -75,6 +84,10 @@ export const QueryWidgetConfig = z.discriminatedUnion("type", [ type: z.literal("chart"), ...chartConfigOptions, }), + z.object({ + type: z.literal("bignumber"), + ...bigNumberConfigOptions, + }), ]); export type QueryWidgetConfig = z.infer; @@ -339,6 +352,31 @@ function QueryWidgetBody({ ); } + case "bignumber": { + return ( + <> + + + + {title} +
+ +
+
+
+ + ); + } default: { assertNever(type); } diff --git a/apps/webapp/app/components/primitives/ClientTabs.tsx b/apps/webapp/app/components/primitives/ClientTabs.tsx index bc3943e82b..737f37bcd1 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/charts/BigNumber.tsx b/apps/webapp/app/components/primitives/charts/BigNumber.tsx deleted file mode 100644 index ab12e9326f..0000000000 --- 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 0000000000..db6c3da71c --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx @@ -0,0 +1,123 @@ +import type { OutputColumnMetadata } from "@internal/tsql"; +import { useMemo } from "react"; +import type { AggregationType, BigNumberConfiguration } from "~/components/metrics/QueryWidget"; +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 + */ +function extractColumnValues(rows: Record[], column: string): number[] { + const values: number[] = []; + for (const row of rows) { + 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; +} + +/** + * 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); + } +} + +/** + * Formats a number for display as a big number. + * Uses K/M suffixes for large values, appropriate decimal places for small values. + */ +function formatBigNumber(value: number): { formatted: string; suffix?: string } { + if (Math.abs(value) >= 1_000_000_000) { + const v = value / 1_000_000_000; + return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), suffix: "B" }; + } + if (Math.abs(value) >= 1_000_000) { + const v = value / 1_000_000; + return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), suffix: "M" }; + } + if (Math.abs(value) >= 1_000) { + const v = value / 1_000; + return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), suffix: "K" }; + } + if (Number.isInteger(value)) { + return { formatted: value.toLocaleString() }; + } + if (Math.abs(value) < 0.01) { + return { formatted: value.toFixed(4) }; + } + if (Math.abs(value) < 1) { + return { formatted: value.toFixed(3) }; + } + return { formatted: value.toFixed(2) }; +} + +export function BigNumberCard({ rows, columns, config, isLoading = false }: BigNumberCardProps) { + const { column, aggregation } = config; + + const result = useMemo(() => { + if (rows.length === 0) return null; + + const values = extractColumnValues(rows, column); + if (values.length === 0) return null; + + return aggregateValues(values, aggregation); + }, [rows, column, aggregation]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (result === null) { + return ( +
+ + No data to display + +
+ ); + } + + const { formatted, suffix } = formatBigNumber(result); + + return ( +
+
+
+ {formatted} + {suffix &&
{suffix}
} +
+
+
+ ); +} diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 909fb56142..1073a4e579 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -65,7 +65,8 @@ import { QueryHelpSidebar } from "~/routes/_app.orgs.$organizationSlug.projects. import { QueryHistoryPopover } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHistoryPopover"; import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types"; import { - ChartConfiguration, + type BigNumberConfiguration, + type ChartConfiguration, QueryWidget, type QueryWidgetConfig, type QueryWidgetData, @@ -131,8 +132,9 @@ export type QueryEditorProps = { defaultScope: QueryScope; defaultPeriod: string; defaultTimeFilter?: { period?: string; from?: string; to?: string }; - defaultResultsView?: "table" | "graph"; + defaultResultsView?: "table" | "graph" | "bignumber"; defaultChartConfig?: ChartConfiguration; + defaultBigNumberConfig?: BigNumberConfiguration; /** Initial result data to display (e.g., when editing an existing widget) */ defaultData?: QueryWidgetData; @@ -305,6 +307,7 @@ const QueryEditorForm = forwardRef< {queryHasTriggeredAt ? ( Set in query @@ -356,6 +359,7 @@ export function QueryEditor({ defaultTimeFilter, defaultResultsView = "table", defaultChartConfig: initialChartConfig, + defaultBigNumberConfig: initialBigNumberConfig, defaultData, history, isAdmin, @@ -393,10 +397,15 @@ export function QueryEditor({ const editorRef = useRef(null); const [prettyFormatting, setPrettyFormatting] = useState(true); - const [resultsView, setResultsView] = useState<"table" | "graph">(defaultResultsView); + const [resultsView, setResultsView] = useState<"table" | "graph" | "bignumber">( + defaultResultsView + ); const [chartConfig, setChartConfig] = useState( initialChartConfig ?? defaultChartConfig ); + const [bigNumberConfig, setBigNumberConfig] = useState( + initialBigNumberConfig ?? { column: "", aggregation: "sum" } + ); const [sidebarTab, setSidebarTab] = useState("ai"); const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null); @@ -419,7 +428,9 @@ export function QueryEditor({ const [userTitle, setUserTitle] = useState(null); // Effective title: user title > edit mode title > history title > generated title - const queryTitle = userTitle ?? (mode.type === "dashboard-edit" + const queryTitle = + userTitle ?? + (mode.type === "dashboard-edit" ? editModeTitle ?? historyTitle ?? generatedTitle ?? null : historyTitle ?? generatedTitle ?? null); @@ -533,6 +544,8 @@ export function QueryEditor({ config: resultsView === "table" ? { type: "table", prettyFormatting, sorting: [] } + : resultsView === "bignumber" + ? { type: "bignumber", ...bigNumberConfig } : { type: "chart", ...chartConfig }, }; @@ -608,7 +621,7 @@ export function QueryEditor({ > setResultsView(v as "table" | "graph")} + onValueChange={(v) => setResultsView(v as "table" | "graph" | "bignumber")} className="grid h-full max-h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden" > Graph + + Big number + {results?.rows ? (
@@ -786,6 +807,7 @@ export function QueryEditor({ accessory={ mode.type === "standalone" ? ( )} + 0 && + hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped) + ? "grid-rows-[auto_1fr]" + : "grid-rows-[1fr]" + }`} + > + {results?.rows && results?.columns && results.rows.length > 0 ? ( + <> + + setIsSaveDialogOpen(true)} + /> + } + content="Save to dashboard" + /> + ) : save ? ( + save(saveData) + ) : undefined + } + /> + + ) : ( + + Run a query to see a big number. + + )} + @@ -846,6 +918,8 @@ export function QueryEditor({ config={ resultsView === "table" ? { type: "table", prettyFormatting, sorting: [] } + : resultsView === "bignumber" + ? { type: "bignumber", ...bigNumberConfig } : { type: "chart", ...chartConfig } } isOpen={isSaveDialogOpen} @@ -1116,3 +1190,161 @@ function ResultsChart({ ); } + +function isNumericColumnType(type: string): boolean { + return ( + type.startsWith("Int") || + type.startsWith("UInt") || + type.startsWith("Float") || + type.startsWith("Decimal") || + type.startsWith("Nullable(Int") || + type.startsWith("Nullable(UInt") || + type.startsWith("Nullable(Float") || + type.startsWith("Nullable(Decimal") + ); +} + +function ResultsBigNumber({ + rows, + columns, + bigNumberConfig, + onBigNumberConfigChange, + queryTitle, + isTitleLoading, + onRenameTitle, + accessory, +}: { + rows: Record[]; + columns: OutputColumnMetadata[]; + bigNumberConfig: BigNumberConfiguration; + onBigNumberConfigChange: (config: BigNumberConfiguration) => void; + queryTitle: string | null; + isTitleLoading: boolean; + onRenameTitle?: (newTitle: string) => void; + accessory?: ReactNode; +}) { + // Auto-select first numeric column if none selected + const numericColumns = columns.filter((c) => isNumericColumnType(c.type)); + + useEffect(() => { + if (!bigNumberConfig.column && numericColumns.length > 0) { + onBigNumberConfigChange({ ...bigNumberConfig, column: numericColumns[0].name }); + } + }, [columns]); + + return ( + <> + + +
+ + } + data={{ + rows, + columns, + }} + config={{ + type: "bignumber", + ...bigNumberConfig, + }} + accessory={accessory} + /> +
+
+ + + + +
+ + ); +} + +const aggregationOptions = [ + { value: "sum", label: "Sum" }, + { value: "avg", label: "Average" }, + { value: "count", label: "Count" }, + { value: "min", label: "Min" }, + { value: "max", label: "Max" }, +] as const; + +function BigNumberConfigPanel({ + columns, + config, + onChange, +}: { + columns: OutputColumnMetadata[]; + config: BigNumberConfiguration; + onChange: (config: BigNumberConfiguration) => void; +}) { + const numericColumns = columns.filter((c) => isNumericColumnType(c.type)); + const allColumns = columns; + + // For count aggregation, any column works; for others, prefer numeric + const availableColumns = config.aggregation === "count" ? allColumns : numericColumns; + + return ( +
+ + Big Number Configuration + +
+
+ + Column + + +
+
+ + Aggregation + + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index e6a27703d2..1e7b891eb4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -318,6 +318,13 @@ export default function Page() { aggregation: state.editorMode.widget.display.aggregation, } : 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, + } + : undefined; const editorDefaultResultsView = state.editorMode.type === "edit" ? state.editorMode.widget.display.type : "table"; // Pass the existing result data when editing @@ -328,6 +335,7 @@ export default function Page() { mode, editorDefaultQuery, editorDefaultChartConfig, + editorDefaultBigNumberConfig, editorDefaultResultsView, editorDefaultData, }; @@ -502,9 +510,14 @@ export default function Page() { defaultScope="environment" defaultPeriod={defaultPeriod} defaultResultsView={ - editorProps.editorDefaultResultsView === "chart" ? "graph" : "table" + editorProps.editorDefaultResultsView === "chart" + ? "graph" + : editorProps.editorDefaultResultsView === "bignumber" + ? "bignumber" + : "table" } defaultChartConfig={editorProps.editorDefaultChartConfig} + defaultBigNumberConfig={editorProps.editorDefaultBigNumberConfig} defaultData={editorProps.editorDefaultData} history={queryHistory} isAdmin={isAdmin} diff --git a/apps/webapp/app/routes/storybook.charts/route.tsx b/apps/webapp/app/routes/storybook.charts/route.tsx index 5c2be023b2..dbd0be67f9 100644 --- a/apps/webapp/app/routes/storybook.charts/route.tsx +++ b/apps/webapp/app/routes/storybook.charts/route.tsx @@ -2,7 +2,7 @@ import { ArrowTrendingUpIcon } from "@heroicons/react/20/solid"; import { IconTimeline } from "@tabler/icons-react"; import { useMemo, useState } from "react"; import { Button } from "~/components/primitives/Buttons"; -import { BigNumber } from "~/components/primitives/charts/BigNumber"; +import { BigNumberCard } from "~/components/primitives/charts/BigNumberCard"; import { Card } from "~/components/primitives/charts/Card"; import { type ChartConfig, type ChartState } from "~/components/primitives/charts/Chart"; import { Chart } from "~/components/primitives/charts/ChartCompound"; @@ -256,7 +256,11 @@ function ChartsDashboard() { - +
From 8d63e27dbc5ff92ee10395df9355edaa8ca9e79f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 8 Feb 2026 16:12:01 +0000 Subject: [PATCH 078/131] Improved BigNumber config and styling --- .../app/components/metrics/QueryWidget.tsx | 19 ++++- .../primitives/charts/BigNumberCard.tsx | 84 ++++++++++++++----- .../app/components/query/QueryEditor.tsx | 77 ++++++++++++++++- .../route.tsx | 4 + 4 files changed, 159 insertions(+), 25 deletions(-) diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index d8157fef87..4f7bd35a2a 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -59,9 +59,26 @@ const chartConfigOptions = { 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: AggregationType, + aggregation: BigNumberAggregationType, + sortDirection: BigNumberSortDirection.optional(), + abbreviate: z.boolean().default(false), + prefix: z.string().optional(), + suffix: z.string().optional(), }; const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions }); diff --git a/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx b/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx index db6c3da71c..5c7c12d9fb 100644 --- a/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx +++ b/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx @@ -1,6 +1,9 @@ import type { OutputColumnMetadata } from "@internal/tsql"; import { useMemo } from "react"; -import type { AggregationType, BigNumberConfiguration } from "~/components/metrics/QueryWidget"; +import type { + BigNumberAggregationType, + BigNumberConfiguration, +} from "~/components/metrics/QueryWidget"; import { Spinner } from "../Spinner"; import { Paragraph } from "../Paragraph"; @@ -12,11 +15,24 @@ interface BigNumberCardProps { } /** - * Extracts numeric values from a specific column across all rows + * Extracts numeric values from a specific column across all rows, + * optionally sorting them first. */ -function extractColumnValues(rows: Record[], column: string): number[] { +function extractColumnValues( + rows: Record[], + column: string, + sortDirection?: "asc" | "desc" +): number[] { const values: number[] = []; - for (const row of rows) { + 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); @@ -30,10 +46,19 @@ function extractColumnValues(rows: Record[], column: string): n 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: AggregationType): number { +function aggregateValues(values: number[], aggregation: BigNumberAggregationType): number { if (values.length === 0) return 0; switch (aggregation) { case "sum": @@ -46,49 +71,59 @@ function aggregateValues(values: number[], aggregation: AggregationType): number return Math.min(...values); case "max": return Math.max(...values); + case "first": + return values[0]; + case "last": + return values[values.length - 1]; } } /** - * Formats a number for display as a big number. - * Uses K/M suffixes for large values, appropriate decimal places for small values. + * Formats a number for display as a big number with abbreviation (K/M/B suffixes). */ -function formatBigNumber(value: number): { formatted: string; suffix?: string } { +function formatBigNumberAbbreviated(value: number): { formatted: string; unitSuffix?: string } { if (Math.abs(value) >= 1_000_000_000) { const v = value / 1_000_000_000; - return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), suffix: "B" }; + return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), unitSuffix: "B" }; } if (Math.abs(value) >= 1_000_000) { const v = value / 1_000_000; - return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), suffix: "M" }; + return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), unitSuffix: "M" }; } if (Math.abs(value) >= 1_000) { const v = value / 1_000; - return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), suffix: "K" }; + return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), unitSuffix: "K" }; } + return { formatted: formatPlainNumber(value) }; +} + +/** + * Formats a number for display without abbreviation. + */ +function formatPlainNumber(value: number): string { if (Number.isInteger(value)) { - return { formatted: value.toLocaleString() }; + return value.toLocaleString(); } if (Math.abs(value) < 0.01) { - return { formatted: value.toFixed(4) }; + return value.toFixed(4); } if (Math.abs(value) < 1) { - return { formatted: value.toFixed(3) }; + return value.toFixed(3); } - return { formatted: value.toFixed(2) }; + return value.toFixed(2); } export function BigNumberCard({ rows, columns, config, isLoading = false }: BigNumberCardProps) { - const { column, aggregation } = config; + const { column, aggregation, sortDirection, abbreviate = false, prefix, suffix } = config; const result = useMemo(() => { if (rows.length === 0) return null; - const values = extractColumnValues(rows, column); + const values = extractColumnValues(rows, column, sortDirection); if (values.length === 0) return null; return aggregateValues(values, aggregation); - }, [rows, column, aggregation]); + }, [rows, column, aggregation, sortDirection]); if (isLoading) { return ( @@ -108,14 +143,23 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN ); } - const { formatted, suffix } = formatBigNumber(result); + const { formatted, unitSuffix } = abbreviate + ? formatBigNumberAbbreviated(result) + : { formatted: formatPlainNumber(result), unitSuffix: undefined }; return (
+ {prefix && {prefix}} {formatted} - {suffix &&
{suffix}
} + {(unitSuffix || suffix) && ( +
+ {unitSuffix} + {unitSuffix && suffix ? " " : ""} + {suffix} +
+ )}
diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 1073a4e579..a1626ce911 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -404,7 +404,7 @@ export function QueryEditor({ initialChartConfig ?? defaultChartConfig ); const [bigNumberConfig, setBigNumberConfig] = useState( - initialBigNumberConfig ?? { column: "", aggregation: "sum" } + initialBigNumberConfig ?? { column: "", aggregation: "sum", abbreviate: true } ); const [sidebarTab, setSidebarTab] = useState("ai"); const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null); @@ -1270,12 +1270,20 @@ function ResultsBigNumber({ ); } -const aggregationOptions = [ +const bigNumberAggregationOptions = [ { value: "sum", label: "Sum" }, { value: "avg", label: "Average" }, { value: "count", label: "Count" }, { value: "min", label: "Min" }, { value: "max", label: "Max" }, + { value: "first", label: "First" }, + { value: "last", label: "Last" }, +] as const; + +const bigNumberSortOptions = [ + { value: "", label: "Unsorted" }, + { value: "asc", label: "Ascending" }, + { value: "desc", label: "Descending" }, ] as const; function BigNumberConfigPanel({ @@ -1321,6 +1329,34 @@ function BigNumberConfigPanel({ }
+
+ + Sort order + + +
Aggregation @@ -1332,8 +1368,10 @@ function BigNumberConfigPanel({ } variant="tertiary/small" dropdownIcon={true} - items={[...aggregationOptions]} - text={(value) => aggregationOptions.find((o) => o.value === value)?.label ?? value} + items={[...bigNumberAggregationOptions]} + text={(value) => + bigNumberAggregationOptions.find((o) => o.value === value)?.label ?? value + } > {(items) => items.map((item) => ( @@ -1344,6 +1382,37 @@ function BigNumberConfigPanel({ }
+
+ onChange({ ...config, abbreviate: checked })} + /> +
+
+ + Prefix + + onChange({ ...config, prefix: e.target.value || undefined })} + placeholder="e.g. $" + variant="small" + /> +
+
+ + Suffix + + onChange({ ...config, suffix: e.target.value || undefined })} + placeholder="e.g. ms" + variant="small" + /> +
); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 1e7b891eb4..542e1c9fc9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -323,6 +323,10 @@ export default function Page() { ? { 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 = From d4075943b740e2566504cfba793ef3f4da0a4913 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 8 Feb 2026 16:24:04 +0000 Subject: [PATCH 079/131] Auto-fit for BigNumber --- .../primitives/charts/BigNumberCard.tsx | 64 ++++++++++--------- .../app/components/query/QueryEditor.tsx | 36 ++++------- 2 files changed, 48 insertions(+), 52 deletions(-) diff --git a/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx b/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx index 5c7c12d9fb..f9d4280412 100644 --- a/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx +++ b/apps/webapp/app/components/primitives/charts/BigNumberCard.tsx @@ -4,6 +4,7 @@ import type { BigNumberAggregationType, BigNumberConfiguration, } from "~/components/metrics/QueryWidget"; +import { AnimatedNumber } from "../AnimatedNumber"; import { Spinner } from "../Spinner"; import { Paragraph } from "../Paragraph"; @@ -79,42 +80,45 @@ function aggregateValues(values: number[], aggregation: BigNumberAggregationType } /** - * Formats a number for display as a big number with abbreviation (K/M/B suffixes). + * 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 formatBigNumberAbbreviated(value: number): { formatted: string; unitSuffix?: string } { +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 { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), unitSuffix: "B" }; + 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 { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), unitSuffix: "M" }; + return { displayValue: v, unitSuffix: "M", decimalPlaces: v % 1 === 0 ? 0 : 1 }; } if (Math.abs(value) >= 1_000) { const v = value / 1_000; - return { formatted: v % 1 === 0 ? v.toFixed(0) : v.toFixed(1), unitSuffix: "K" }; + return { displayValue: v, unitSuffix: "K", decimalPlaces: v % 1 === 0 ? 0 : 1 }; } - return { formatted: formatPlainNumber(value) }; + return { displayValue: value, decimalPlaces: getDecimalPlaces(value) }; } /** - * Formats a number for display without abbreviation. + * Determines decimal places for plain (non-abbreviated) display. */ -function formatPlainNumber(value: number): string { - if (Number.isInteger(value)) { - return value.toLocaleString(); - } - if (Math.abs(value) < 0.01) { - return value.toFixed(4); - } - if (Math.abs(value) < 1) { - return value.toFixed(3); - } - return value.toFixed(2); +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 = false, prefix, suffix } = config; + const { column, aggregation, sortDirection, abbreviate = true, prefix, suffix } = config; const result = useMemo(() => { if (rows.length === 0) return null; @@ -127,7 +131,7 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN if (isLoading) { return ( -
+
); @@ -135,7 +139,7 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN if (result === null) { return ( -
+
No data to display @@ -143,22 +147,22 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN ); } - const { formatted, unitSuffix } = abbreviate - ? formatBigNumberAbbreviated(result) - : { formatted: formatPlainNumber(result), unitSuffix: undefined }; + const { displayValue, unitSuffix, decimalPlaces } = abbreviate + ? abbreviateValue(result) + : { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) }; return ( -
-
-
+
+
+
{prefix && {prefix}} - {formatted} + {(unitSuffix || suffix) && ( -
+ {unitSuffix} {unitSuffix && suffix ? " " : ""} {suffix} -
+ )}
diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index a1626ce911..2d1ad53a19 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -806,17 +806,13 @@ export function QueryEditor({ onRenameTitle={handleRenameTitle} accessory={ mode.type === "standalone" ? ( - setIsSaveDialogOpen(true)} - /> - } - content="Save to dashboard" - /> + ) : save ? ( save(saveData) ) : undefined @@ -856,17 +852,13 @@ export function QueryEditor({ onRenameTitle={handleRenameTitle} accessory={ mode.type === "standalone" ? ( - setIsSaveDialogOpen(true)} - /> - } - content="Save to dashboard" - /> + ) : save ? ( save(saveData) ) : undefined From 0a82d0db6f609a6687d7222bd6343d1067f39b66 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 9 Feb 2026 16:21:28 +0000 Subject: [PATCH 080/131] Fix for useFetcher errors bubbling up to the web inspector --- apps/webapp/app/routes/resources.metric.tsx | 67 ++++++++++++++++----- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index b8bcfc40de..c5f78499ff 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -1,7 +1,6 @@ import type { OutputColumnMetadata } from "@internal/clickhouse"; -import { useFetcher } from "@remix-run/react"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { z } from "zod"; import { requireUserId } from "~/services/session.server"; import { hasAccessToEnvironment } from "~/models/runtimeEnvironment.server"; @@ -164,24 +163,60 @@ export function MetricWidget({ onDuplicate, ...props }: MetricWidgetProps) { - const fetcher = useFetcher(); - const isLoading = fetcher.state !== "idle"; + const [response, setResponse] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const abortControllerRef = useRef(null); - const submit = useCallback(async () => { - fetcher.submit(props, { + // 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(() => { + // 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", - action: `/resources/metric`, - encType: "application/json", - }); - }, [JSON.stringify(props)]); + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(propsRef.current), + signal: controller.signal, + }) + .then((res) => res.json() as Promise) + .then((data) => { + if (!controller.signal.aborted) { + setResponse(data); + setIsLoading(false); + } + }) + .catch((err) => { + // Ignore aborted requests + if (err instanceof DOMException && err.name === "AbortError") return; + if (!controller.signal.aborted) { + setIsLoading(false); + } + }); + }, []); + + // Clean up on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); - // Reload periodically and on focus - useInterval({ interval: refreshIntervalMs, callback: submit }); + // Reload periodically and on focus (onLoad: false — the useEffect below handles initial load) + useInterval({ interval: refreshIntervalMs, callback: submit, onLoad: false }); - // Reload when query, time period, or filters change + // Reload on mount and when query, time period, or filters change useEffect(() => { submit(); }, [ + submit, props.query, props.from, props.to, @@ -191,8 +226,8 @@ export function MetricWidget({ JSON.stringify(props.queues), ]); - const data = fetcher.data?.success - ? { rows: fetcher.data.data.rows, columns: fetcher.data.data.columns } + const data = response?.success + ? { rows: response.data.rows, columns: response.data.columns } : { rows: [], columns: [] }; return ( @@ -202,7 +237,7 @@ export function MetricWidget({ config={config} isLoading={isLoading} data={data} - error={fetcher.data?.success === false ? fetcher.data.error : undefined} + error={response?.success === false ? response.error : undefined} isResizing={isResizing} isDraggable={isDraggable} onEdit={onEdit} From ade776e89b30636e1bf663974e4c95df77952b9c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 9 Feb 2026 16:39:13 +0000 Subject: [PATCH 081/131] Added a title widget type and the ability to add them --- .../app/components/metrics/QueryWidget.tsx | 7 + .../app/components/metrics/TitleWidget.tsx | 126 ++++++++++++++++ apps/webapp/app/hooks/useDashboardEditor.ts | 40 ++++- .../v3/MetricDashboardPresenter.server.ts | 4 +- .../route.tsx | 94 +++++++----- .../route.tsx | 139 +++++++++++++----- ...vParam.dashboards.$dashboardId.widgets.tsx | 26 +++- 7 files changed, 351 insertions(+), 85 deletions(-) create mode 100644 apps/webapp/app/components/metrics/TitleWidget.tsx diff --git a/apps/webapp/app/components/metrics/QueryWidget.tsx b/apps/webapp/app/components/metrics/QueryWidget.tsx index 4f7bd35a2a..9d43f16697 100644 --- a/apps/webapp/app/components/metrics/QueryWidget.tsx +++ b/apps/webapp/app/components/metrics/QueryWidget.tsx @@ -105,6 +105,9 @@ export const QueryWidgetConfig = z.discriminatedUnion("type", [ type: z.literal("bignumber"), ...bigNumberConfigOptions, }), + z.object({ + type: z.literal("title"), + }), ]); export type QueryWidgetConfig = z.infer; @@ -394,6 +397,10 @@ function QueryWidgetBody({ ); } + case "title": { + // Title widgets are rendered by TitleWidget, not QueryWidget + return null; + } default: { assertNever(type); } diff --git a/apps/webapp/app/components/metrics/TitleWidget.tsx b/apps/webapp/app/components/metrics/TitleWidget.tsx new file mode 100644 index 0000000000..b9ac135dc8 --- /dev/null +++ b/apps/webapp/app/components/metrics/TitleWidget.tsx @@ -0,0 +1,126 @@ +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 && ( + + + Rename title +
{ + e.preventDefault(); + if (renameValue.trim()) { + onRename(renameValue.trim()); + setIsRenameDialogOpen(false); + } + }} + > + + + setRenameValue(e.target.value)} + placeholder="Section title" + autoFocus + /> + + + + + + + +
+
+
+ )} +
+ ); +} diff --git a/apps/webapp/app/hooks/useDashboardEditor.ts b/apps/webapp/app/hooks/useDashboardEditor.ts index 8272207347..384b8dca35 100644 --- a/apps/webapp/app/hooks/useDashboardEditor.ts +++ b/apps/webapp/app/hooks/useDashboardEditor.ts @@ -329,10 +329,15 @@ export function useDashboardEditor({ // 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 - if (widgetLimit !== undefined && Object.keys(state.widgets).length >= widgetLimit) { + // Guard: check widget limit (title widgets don't count) + if (widgetLimit !== undefined && countedWidgets >= widgetLimit) { onWidgetLimitReached?.(); return; } @@ -352,7 +357,29 @@ export function useDashboardEditor({ config: JSON.stringify(config), }); }, - [state.layout, state.widgets, widgetLimit, onWidgetLimitReached, queueWidgetSync] + [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) + queueWidgetSync("add", { + title, + query: "", + config: JSON.stringify(config), + }); + }, + [state.layout, queueWidgetSync] ); const updateWidget = useCallback( @@ -386,8 +413,8 @@ export function useDashboardEditor({ const duplicateWidget = useCallback( (widgetId: string) => { - // Guard: check widget limit - if (widgetLimit !== undefined && Object.keys(state.widgets).length >= widgetLimit) { + // Guard: check widget limit (title widgets don't count) + if (widgetLimit !== undefined && countedWidgets >= widgetLimit) { onWidgetLimitReached?.(); return; } @@ -402,7 +429,7 @@ export function useDashboardEditor({ // This is fine since we're optimistic - the server state will be consistent queueWidgetSync("duplicate", { widgetId }); }, - [state.widgets, widgetLimit, onWidgetLimitReached, queueWidgetSync] + [countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync] ); const renameWidget = useCallback( @@ -471,6 +498,7 @@ export function useDashboardEditor({ /** Action dispatchers */ actions: { addWidget, + addTitleWidget, updateWidget, renameWidget, deleteWidget, diff --git a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts index bb2b9d5e5e..34385bf4c9 100644 --- a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts @@ -26,13 +26,15 @@ export const LayoutItem = z.object({ y: z.number(), w: z.number(), h: z.number(), + minH: z.number().optional(), + maxH: z.number().optional(), }); export type LayoutItem = z.infer; export const Widget = z.object({ title: z.string(), - query: z.string(), + query: z.string().default(""), display: QueryWidgetConfig, }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index bf39751bab..82a261afe8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -15,9 +15,10 @@ 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, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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"; @@ -153,6 +154,19 @@ export function MetricDashboard({ [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 (
@@ -173,7 +187,7 @@ export function MetricDashboard({ > {mounted && ( {Object.entries(widgets).map(([key, widget]) => (
- 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 - } - /> + {widget.display.type === "title" ? ( + onRenameWidget(key, newTitle) : undefined + } + onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined} + /> + ) : ( + 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 + } + /> + )}
))}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index 542e1c9fc9..f4e1b5df65 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -227,6 +227,10 @@ export default function Page() { )); }, []); + // 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); @@ -244,8 +248,11 @@ export default function Page() { onWidgetLimitReached: handleWidgetLimitReached, }); - // Reactive widget count from editor state - const currentWidgetCount = Object.keys(state.widgets).length; + // 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 widgetCanUpgrade = plan?.v3Subscription?.plan && !canExceedWidgets; @@ -351,41 +358,66 @@ export default function Page() { - {currentWidgetCount > 0 && + {totalWidgetCount > 0 && (widgetIsAtLimit ? ( - - - - - - You've exceeded your widget limit - - You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard. - - - {widgetCanUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" - /> - )} - - - + <> + + + + + + You've exceeded your widget limit + + You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this + dashboard. + + + {widgetCanUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + + + + + ) : ( - + <> + + + ))} @@ -401,7 +433,7 @@ export default function Page() {
- {currentWidgetCount === 0 ? ( + {totalWidgetCount === 0 ? ( + {/* 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 */} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx index ab9e99498c..beaeece39f 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx @@ -17,7 +17,7 @@ import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuild // Schemas for each action type const AddWidgetSchema = z.object({ title: z.string().min(1, "Title is required"), - query: z.string().min(1, "Query is required"), + query: z.string().default(""), config: z.string().transform((str, ctx) => { try { const parsed = JSON.parse(str); @@ -167,10 +167,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } } - // Check widget limit for add/duplicate actions + // Check widget limit for add/duplicate actions (title widgets don't count) async function checkWidgetLimit() { - const currentWidgetCount = Object.keys(existingLayout.widgets).length; - const plan = await getCurrentPlan(project.organizationId); + const currentWidgetCount = Object.values(existingLayout.widgets).filter( + (w) => w.display.type !== "title" + ).length; + const plan = await getCurrentPlan(project!.organizationId); const metricWidgetsLimitValue = (plan?.v3Subscription?.plan?.limits as any) ?.metricWidgetsPerDashboard; const widgetLimit = @@ -185,8 +187,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (action) { case "add": { - await checkWidgetLimit(); - const rawData = { title: formData.get("title"), query: formData.get("query"), @@ -200,6 +200,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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(); + } + // Generate new widget ID const widgetId = nanoid(8); @@ -212,13 +222,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } } - // Add new layout item (full width, reasonable height) + // Add new layout item (full width, height depends on widget type) const newLayoutItem = { i: widgetId, x: 0, y: maxBottom, w: 12, - h: 15, + h: config.type === "title" ? 2 : 15, }; // Add new widget From 9776c538b380e85260c5e95cde98b4822e932bb4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 9 Feb 2026 18:13:18 +0000 Subject: [PATCH 082/131] Fix for duplicating widget id clash --- apps/webapp/app/hooks/useDashboardEditor.ts | 9 ++++--- ...vParam.dashboards.$dashboardId.widgets.tsx | 27 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/hooks/useDashboardEditor.ts b/apps/webapp/app/hooks/useDashboardEditor.ts index 384b8dca35..65b2ccca6b 100644 --- a/apps/webapp/app/hooks/useDashboardEditor.ts +++ b/apps/webapp/app/hooks/useDashboardEditor.ts @@ -351,7 +351,9 @@ export function useDashboardEditor({ 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), @@ -373,7 +375,9 @@ export function useDashboardEditor({ 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), @@ -425,9 +429,8 @@ export function useDashboardEditor({ dispatch({ type: "DUPLICATE_WIDGET", payload: { id: widgetId, newId } }); // Queue sync to server (processed sequentially) - // Note: Server will generate its own ID, but our local state uses newId - // This is fine since we're optimistic - the server state will be consistent - queueWidgetSync("duplicate", { widgetId }); + // Send the client-generated newId so the server uses the same ID for the duplicate + queueWidgetSync("duplicate", { widgetId, newId }); }, [countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync] ); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx index beaeece39f..d0d2069c22 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardId.widgets.tsx @@ -1,4 +1,4 @@ -import { type ActionFunctionArgs, redirect } from "@remix-run/node"; +import { type ActionFunctionArgs } from "@remix-run/node"; import { nanoid } from "nanoid"; import { z } from "zod"; import { typedjson } from "remix-typedjson"; @@ -12,10 +12,11 @@ import { } from "~/presenters/v3/MetricDashboardPresenter.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; // Schemas for each action type const AddWidgetSchema = z.object({ + widgetId: z.string().min(1, "Widget ID is required").optional(), title: z.string().min(1, "Title is required"), query: z.string().default(""), config: z.string().transform((str, ctx) => { @@ -77,6 +78,7 @@ const DeleteWidgetSchema = z.object({ const DuplicateWidgetSchema = z.object({ widgetId: z.string().min(1, "Widget ID is required"), + newId: z.string().min(1, "New widget ID is required").optional(), }); const SaveLayoutSchema = z.object({ @@ -188,6 +190,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (action) { case "add": { const rawData = { + widgetId: formData.get("widgetId"), title: formData.get("title"), query: formData.get("query"), config: formData.get("config"), @@ -210,8 +213,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await checkWidgetLimit(); } - // Generate new widget ID - const widgetId = nanoid(8); + // 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; @@ -256,14 +260,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); - // Redirect to the dashboard - const dashboardPath = v3CustomDashboardPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam }, - { friendlyId: dashboardId } - ); - return redirect(dashboardPath); + return typedjson({ success: true, widgetId }); } case "update": { @@ -395,6 +392,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const rawData = { widgetId: formData.get("widgetId"), + newId: formData.get("newId"), }; const result = DuplicateWidgetSchema.safeParse(rawData); @@ -416,8 +414,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { throw new Response("Widget layout not found", { status: 404 }); } - // Generate new widget ID - const newWidgetId = nanoid(8); + // 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; From 4d0648a0163260c7fa70e92b5fb774073c4e83ad Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 9 Feb 2026 18:22:38 +0000 Subject: [PATCH 083/131] Rounded corners for the Sheet --- .../route.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx index f4e1b5df65..4910a97c72 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.custom.$dashboardId/route.tsx @@ -538,7 +538,7 @@ export default function Page() { !open && actions.closeEditor()}> {editorProps && ( Date: Mon, 9 Feb 2026 18:26:08 +0000 Subject: [PATCH 084/131] Remove Beta badge --- apps/webapp/app/components/query/QueryEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 2d1ad53a19..32dba03281 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -555,7 +555,7 @@ export function QueryEditor({ case "standalone": return ( - Query} /> + ); case "dashboard-add": From f2b4f884d36f18cfa2d67f2e71578daf1af9c174 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 9 Feb 2026 18:58:28 +0000 Subject: [PATCH 085/131] Fixed history popover scrolling + improved the styling --- .../QueryHistoryPopover.tsx | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHistoryPopover.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHistoryPopover.tsx index 66492ca944..65c10f3f9d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHistoryPopover.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/QueryHistoryPopover.tsx @@ -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({ - -
+
{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 ( ); })}
- + ); } - From a1db285dcfc93d97f0085de32e887b3cd66f5810 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 9 Feb 2026 19:17:30 +0000 Subject: [PATCH 086/131] Nicer query blank state --- .../app/components/query/QueryEditor.tsx | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 32dba03281..0528a0a0e7 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -5,6 +5,7 @@ import { PencilIcon, XMarkIcon, } from "@heroicons/react/20/solid"; +import { IconChartHistogram } from "@tabler/icons-react"; import type { OutputColumnMetadata } from "@internal/clickhouse"; import { useFetcher } from "@remix-run/react"; import { @@ -774,9 +775,12 @@ export function QueryEditor({
) : ( - - Run a query to see results here. - +
+ + + Run a query to visualize the results. + +
)} ) : ( - - Run a query to visualize results. - +
+ + + Run a query to visualize the results. + +
)}
) : ( - - Run a query to see a big number. - +
+ + + Run a query to visualize the results. + +
)}
From 6e2154e33839e911a75834ad9954d0c60bd1027c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 9 Feb 2026 19:26:17 +0000 Subject: [PATCH 087/131] Match the AI example query buttons with the AskAI examples --- .../AITabContent.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/AITabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/AITabContent.tsx index b66c66841c..3c9fb57cb4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/AITabContent.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/AITabContent.tsx @@ -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({ />
- Example prompts -
+ Example prompts +
{examplePrompts.map((example) => ( ))}
@@ -63,4 +68,3 @@ export function AITabContent({
); } - From 7f82c914f9c6a9ed126d6a5f7e32e9947359eed2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 9 Feb 2026 21:19:32 +0000 Subject: [PATCH 088/131] Use ellipsis --- apps/webapp/app/components/query/QueryEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 0528a0a0e7..c83cd0a764 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -690,7 +690,7 @@ export function QueryEditor({ {isLoading ? (
- Executing query... + Executing query…
) : results?.error ? (
From fdcfa745fef02d5fe0ee83f6b5c779c2ce437adb Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 9 Feb 2026 21:19:54 +0000 Subject: [PATCH 089/131] Style updates and remove the tool call displaying in the UI --- .../app/components/code/AIQueryInput.tsx | 147 ++++++++---------- 1 file changed, 64 insertions(+), 83 deletions(-) diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx index 38d0c9b21b..c587cae59e 100644 --- a/apps/webapp/app/components/code/AIQueryInput.tsx +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -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 ( -
+
{/* Gradient border wrapper like the schedules AI input */}
-
+