Query page WIP
This commit is contained in:
@@ -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">
|
||||
|
||||
+254
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user