diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index bda24a32e..719a82695 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -5,6 +5,7 @@ import { BellAlertIcon, ChartBarIcon, ChevronRightIcon, + CircleStackIcon, ClockIcon, Cog8ToothIcon, CogIcon, @@ -13,11 +14,13 @@ import { GlobeAmericasIcon, IdentificationIcon, KeyIcon, + MagnifyingGlassCircleIcon, PencilSquareIcon, PlusIcon, RectangleStackIcon, ServerStackIcon, Squares2X2Icon, + TableCellsIcon, UsersIcon, } from "@heroicons/react/20/solid"; import { Link, useNavigation } from "@remix-run/react"; @@ -51,6 +54,7 @@ import { organizationPath, organizationSettingsPath, organizationTeamPath, + queryPath, regionsPath, v3ApiKeysPath, v3BatchesPath, @@ -267,6 +271,15 @@ export function SideMenu({ to={v3TestPath(organization, project, environment)} data-action="test" /> + {user.admin && ( + + )} 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 new file mode 100644 index 000000000..e5c2c4b99 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -0,0 +1,254 @@ +import { CircleStackIcon } from "@heroicons/react/20/solid"; +import { Form, useNavigation } from "@remix-run/react"; +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + redirect, +} from "@remix-run/server-runtime"; +import { useState } from "react"; +import { typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { TSQLEditor } from "~/components/code/TSQLEditor"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { Spinner } from "~/components/primitives/Spinner"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { executeQuery } from "~/services/queryService.server"; +import { requireUser } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { defaultQuery, querySchemas } from "~/v3/querySchemas"; + +const scopeOptions = [ + { value: "environment", label: "Environment" }, + { value: "project", label: "Project" }, + { value: "organization", label: "Organization" }, +] as const; + +type QueryScope = (typeof scopeOptions)[number]["value"]; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + + if (!user.admin) { + throw redirect("/"); + } + + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + return typedjson({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + defaultQuery, + }); +}; + +const ActionSchema = z.object({ + query: z.string().min(1, "Query is required"), + scope: z.enum(["environment", "project", "organization"]), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const user = await requireUser(request); + + // Temporarily admin-only + if (!user.admin) { + return typedjson({ error: "Unauthorized", rows: null }, { status: 403 }); + } + + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { + return typedjson({ error: "Project not found", rows: null }, { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, user.id); + if (!environment) { + return typedjson({ error: "Environment not found", rows: null }, { status: 404 }); + } + + const formData = await request.formData(); + const parsed = ActionSchema.safeParse({ + query: formData.get("query"), + scope: formData.get("scope"), + }); + + if (!parsed.success) { + return typedjson( + { error: parsed.error.errors.map((e) => e.message).join(", "), rows: null }, + { status: 400 } + ); + } + + const { query, scope } = parsed.data; + + // Build tenant IDs based on scope + const tenantOptions: { + organizationId: string; + projectId?: string; + environmentId?: string; + } = { + organizationId: project.organizationId, + }; + + if (scope === "project" || scope === "environment") { + tenantOptions.projectId = project.id; + } + + if (scope === "environment") { + tenantOptions.environmentId = environment.id; + } + + try { + const [error, rows] = await executeQuery({ + name: "query-page", + query, + schema: z.record(z.any()), + tableSchema: querySchemas, + transformValues: false, + ...tenantOptions, + }); + + if (error) { + return typedjson({ error: error.message, rows: null }, { status: 400 }); + } + + return typedjson({ error: null, rows }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error executing query"; + return typedjson({ error: errorMessage, rows: null }, { status: 500 }); + } +}; + +export default function Page() { + const { organizationId, projectId, environmentId, defaultQuery } = + useTypedLoaderData(); + const actionData = useTypedActionData(); + const navigation = useNavigation(); + + const [query, setQuery] = useState(defaultQuery); + const [scope, setScope] = useState("environment"); + + const isLoading = navigation.state === "submitting" || navigation.state === "loading"; + + return ( + + + + + + + {/* Editor */} + + + SQL Query + + Query task runs using SQL. Results are scoped to your selected tenant level. + + + + + + + + {/* Controls */} + + + + + + + Scope: + + + value={scope} + setValue={(value) => setScope(value as QueryScope)} + variant="secondary/small" + dropdownIcon={true} + items={[...scopeOptions]} + > + {(items) => + items.map((item) => ( + + {item.label} + + )) + } + + + + + {isLoading ? ( + <> + + Querying... + > + ) : ( + "Query" + )} + + + + {/* Results */} + + Results + + {isLoading ? ( + + + Executing query... + + ) : actionData?.error ? ( + {actionData.error} + ) : actionData?.rows ? ( + + {JSON.stringify(actionData.rows, null, 2)} + + ) : ( + + Run a query to see results here. + + )} + + + + + + ); +} diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts new file mode 100644 index 000000000..1cc95a144 --- /dev/null +++ b/apps/webapp/app/services/queryService.server.ts @@ -0,0 +1,15 @@ +import { executeTSQL, type ExecuteTSQLOptions, type TSQLQueryResult } from "@internal/clickhouse"; +import type { TableSchema } from "@internal/tsql"; +import { type z } from "zod"; +import { clickhouseClient } from "./clickhouseInstance.server"; + +export type { TableSchema, TSQLQueryResult }; + +/** + * Execute a TSQL query against ClickHouse with tenant isolation + */ +export async function executeQuery( + options: Omit, "tableSchema"> & { tableSchema: TableSchema[] } +): Promise>> { + return executeTSQL(clickhouseClient.reader, options); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index f55ce8028..36582e69d 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -242,6 +242,14 @@ export function v3TestPath( return `${v3EnvironmentPath(organization, project, environment)}/test`; } +export function queryPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/query`; +} + export function v3TestTaskPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/querySchemas.ts b/apps/webapp/app/v3/querySchemas.ts new file mode 100644 index 000000000..633a30465 --- /dev/null +++ b/apps/webapp/app/v3/querySchemas.ts @@ -0,0 +1,270 @@ +import { column, type TableSchema } from "@internal/tsql"; + +/** + * Run status values in ClickHouse + */ +const RUN_STATUSES = [ + "DELAYED", + "PENDING", + "PENDING_VERSION", + "WAITING_FOR_DEPLOY", + "DEQUEUED", + "EXECUTING", + "WAITING_TO_RESUME", + "RETRYING_AFTER_FAILURE", + "PAUSED", + "CANCELED", + "INTERRUPTED", + "COMPLETED_SUCCESSFULLY", + "COMPLETED_WITH_ERRORS", + "SYSTEM_FAILURE", + "CRASHED", + "EXPIRED", + "TIMED_OUT", +] as const; + +/** + * Environment type values + */ +const ENVIRONMENT_TYPES = ["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"] as const; + +/** + * Engine type values + */ +const ENGINE_TYPES = ["V1", "V2"] as const; + +/** + * Machine preset values + */ +const MACHINE_PRESETS = [ + "micro", + "small-1x", + "small-2x", + "medium-1x", + "medium-2x", + "large-1x", + "large-2x", +] as const; + +/** + * Schema definition for the runs table (trigger_dev.task_runs_v2) + */ +export const runsSchema: TableSchema = { + name: "runs", + clickhouseName: "trigger_dev.task_runs_v2", + description: "Task runs - stores all task execution records", + tenantColumns: { + organizationId: "organization_id", + projectId: "project_id", + environmentId: "environment_id", + }, + columns: { + // IDs & hierarchy + run_id: { + name: "run_id", + ...column("String", { description: "Unique run identifier" }), + }, + friendly_id: { + name: "friendly_id", + ...column("String", { description: "Human-readable run ID (e.g., run_abc123)" }), + }, + environment_id: { + name: "environment_id", + ...column("String", { description: "Environment ID" }), + }, + organization_id: { + name: "organization_id", + ...column("String", { description: "Organization ID" }), + }, + project_id: { + name: "project_id", + ...column("String", { description: "Project ID" }), + }, + environment_type: { + name: "environment_type", + ...column("LowCardinality(String)", { + description: "Environment type", + allowedValues: [...ENVIRONMENT_TYPES], + }), + }, + attempt: { + name: "attempt", + ...column("UInt8", { description: "Attempt number (starts at 1)" }), + }, + + // Status & engine + engine: { + name: "engine", + ...column("LowCardinality(String)", { + description: "Run engine version", + allowedValues: [...ENGINE_TYPES], + }), + }, + status: { + name: "status", + ...column("LowCardinality(String)", { + description: "Run status", + allowedValues: [...RUN_STATUSES], + }), + }, + + // Task & queue + task_identifier: { + name: "task_identifier", + ...column("String", { description: "Task identifier/slug" }), + }, + queue: { + name: "queue", + ...column("String", { description: "Queue name" }), + }, + schedule_id: { + name: "schedule_id", + ...column("String", { description: "Schedule ID (if triggered by schedule)" }), + }, + batch_id: { + name: "batch_id", + ...column("String", { description: "Batch ID (if part of a batch)" }), + }, + + // Related runs + root_run_id: { + name: "root_run_id", + ...column("String", { description: "Root run ID (for child runs)" }), + }, + parent_run_id: { + name: "parent_run_id", + ...column("String", { description: "Parent run ID (for child runs)" }), + }, + depth: { + name: "depth", + ...column("UInt8", { description: "Nesting depth (0 for root runs)" }), + }, + + // Telemetry + span_id: { + name: "span_id", + ...column("String", { description: "OpenTelemetry span ID" }), + }, + trace_id: { + name: "trace_id", + ...column("String", { description: "OpenTelemetry trace ID" }), + }, + idempotency_key: { + name: "idempotency_key", + ...column("String", { description: "Idempotency key" }), + }, + + // Timing + created_at: { + name: "created_at", + ...column("DateTime64", { description: "When the run was created" }), + }, + updated_at: { + name: "updated_at", + ...column("DateTime64", { description: "When the run was last updated" }), + }, + started_at: { + name: "started_at", + ...column("Nullable(DateTime64)", { description: "When the run started executing" }), + }, + executed_at: { + name: "executed_at", + ...column("Nullable(DateTime64)", { description: "When execution began" }), + }, + completed_at: { + name: "completed_at", + ...column("Nullable(DateTime64)", { description: "When the run completed" }), + }, + delay_until: { + name: "delay_until", + ...column("Nullable(DateTime64)", { description: "Delayed execution until this time" }), + }, + queued_at: { + name: "queued_at", + ...column("Nullable(DateTime64)", { description: "When the run was queued" }), + }, + expired_at: { + name: "expired_at", + ...column("Nullable(DateTime64)", { description: "When the run expired" }), + }, + expiration_ttl: { + name: "expiration_ttl", + ...column("String", { description: "TTL string for expiration" }), + }, + + // Cost & usage + usage_duration_ms: { + name: "usage_duration_ms", + ...column("UInt32", { description: "Usage duration in milliseconds" }), + }, + cost_in_cents: { + name: "cost_in_cents", + ...column("Float64", { description: "Cost in cents" }), + }, + base_cost_in_cents: { + name: "base_cost_in_cents", + ...column("Float64", { description: "Base cost in cents" }), + }, + + // Output & error (JSON columns) + output: { + name: "output", + ...column("JSON", { description: "Run output data" }), + }, + error: { + name: "error", + ...column("JSON", { description: "Error information" }), + }, + + // Tags & versions + tags: { + name: "tags", + ...column("Array(String)", { description: "Run tags" }), + }, + task_version: { + name: "task_version", + ...column("String", { description: "Task version" }), + }, + sdk_version: { + name: "sdk_version", + ...column("String", { description: "SDK version" }), + }, + cli_version: { + name: "cli_version", + ...column("String", { description: "CLI version" }), + }, + machine_preset: { + name: "machine_preset", + ...column("LowCardinality(String)", { + description: "Machine preset", + allowedValues: [...MACHINE_PRESETS], + }), + }, + + // Flags + is_test: { + name: "is_test", + ...column("UInt8", { description: "Whether this is a test run (0 or 1)" }), + }, + }, +}; + +/** + * All available schemas for the query editor + */ +export const querySchemas: TableSchema[] = [runsSchema]; + +/** + * Default query for the query editor + */ +export const defaultQuery = `SELECT + run_id, + friendly_id, + task_identifier, + status, + created_at, + usage_duration_ms +FROM runs +ORDER BY created_at DESC +LIMIT 10`; +
{actionData.error}
+ {JSON.stringify(actionData.rows, null, 2)} +