Query page WIP

This commit is contained in:
Matt Aitken
2025-12-17 16:30:42 +00:00
parent ee7dd03d73
commit 0b80075570
5 changed files with 560 additions and 0 deletions
@@ -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 && (
<SideMenuItem
name="Query"
icon={TableCellsIcon}
activeIconColor="text-purple-500"
to={queryPath(organization, project, environment)}
data-action="query"
/>
)}
</div>
<SideMenuSection title="Waitpoints">
@@ -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<typeof loader>();
const actionData = useTypedActionData<typeof action>();
const navigation = useNavigation();
const [query, setQuery] = useState(defaultQuery);
const [scope, setScope] = useState<QueryScope>("environment");
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
return (
<PageContainer>
<NavBar>
<PageTitle title="Query" />
</NavBar>
<PageBody scrollable={false}>
<div className="flex h-full flex-col gap-4 p-4">
{/* Editor */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Header2>SQL Query</Header2>
<Paragraph variant="small" className="text-text-dimmed">
Query task runs using SQL. Results are scoped to your selected tenant level.
</Paragraph>
</div>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue={query}
onChange={setQuery}
schema={querySchemas}
linterEnabled={true}
showCopyButton={true}
showClearButton={true}
minHeight="200px"
className="min-h-[200px]"
/>
</div>
</div>
{/* Controls */}
<Form method="post" className="flex items-center gap-3">
<input type="hidden" name="query" value={query} />
<input type="hidden" name="scope" value={scope} />
<div className="flex items-center gap-2">
<Paragraph variant="small" className="text-text-dimmed">
Scope:
</Paragraph>
<Select<QueryScope, (typeof scopeOptions)[number]>
value={scope}
setValue={(value) => setScope(value as QueryScope)}
variant="secondary/small"
dropdownIcon={true}
items={[...scopeOptions]}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))
}
</Select>
</div>
<Button
type="submit"
variant="primary/medium"
disabled={isLoading || !query.trim()}
shortcut={{ modifiers: ["mod"], key: "enter", enabledOnInputElements: true }}
>
{isLoading ? (
<>
<Spinner className="size-4" color="white" />
Querying...
</>
) : (
"Query"
)}
</Button>
</Form>
{/* Results */}
<div className="flex min-h-0 flex-1 flex-col gap-2">
<Header2>Results</Header2>
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-grid-dimmed bg-charcoal-900 p-4">
{isLoading ? (
<div className="flex items-center gap-2 text-text-dimmed">
<Spinner className="size-4" />
<span>Executing query...</span>
</div>
) : actionData?.error ? (
<pre className="whitespace-pre-wrap text-sm text-red-400">{actionData.error}</pre>
) : actionData?.rows ? (
<pre className="whitespace-pre-wrap text-sm text-text-bright">
{JSON.stringify(actionData.rows, null, 2)}
</pre>
) : (
<Paragraph variant="small" className="text-text-dimmed">
Run a query to see results here.
</Paragraph>
)}
</div>
</div>
</div>
</PageBody>
</PageContainer>
);
}
@@ -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<TOut extends z.ZodSchema>(
options: Omit<ExecuteTSQLOptions<TOut>, "tableSchema"> & { tableSchema: TableSchema[] }
): Promise<TSQLQueryResult<z.output<TOut>>> {
return executeTSQL(clickhouseClient.reader, options);
}
+8
View File
@@ -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,
+270
View File
@@ -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`;