chore: import upstream snapshot with attribution
This commit is contained in:
+75
@@ -0,0 +1,75 @@
|
||||
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({
|
||||
onQueryGenerated,
|
||||
onTimeFilterChange,
|
||||
getCurrentQuery,
|
||||
aiFixRequest,
|
||||
}: {
|
||||
onQueryGenerated: (query: string) => void;
|
||||
onTimeFilterChange?: (filter: AITimeFilter) => void;
|
||||
getCurrentQuery: () => string;
|
||||
aiFixRequest: { prompt: string; key: number } | null;
|
||||
}) {
|
||||
const [examplePromptRequest, setExamplePromptRequest] = useState<{
|
||||
prompt: string;
|
||||
key: number;
|
||||
} | null>(null);
|
||||
|
||||
// Use aiFixRequest if present, otherwise use example prompt request
|
||||
const activeRequest = aiFixRequest ?? examplePromptRequest;
|
||||
|
||||
const examplePrompts = [
|
||||
"Show me failed runs by hour for the past 7 days",
|
||||
"Count of runs by status by hour for the past 48h",
|
||||
"Top 50 most expensive runs this week",
|
||||
"Average execution duration by task this week",
|
||||
"Run counts by tag in the past 7 days",
|
||||
"CPU utilization over time by task",
|
||||
"Peak memory usage per run",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<AIQueryInput
|
||||
onQueryGenerated={onQueryGenerated}
|
||||
onTimeFilterChange={onTimeFilterChange}
|
||||
autoSubmitPrompt={activeRequest?.prompt}
|
||||
autoSubmitKey={activeRequest?.key}
|
||||
getCurrentQuery={getCurrentQuery}
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examplePrompts.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExamplePromptRequest((prev) => ({
|
||||
prompt: example,
|
||||
key: (prev?.key ?? 0) + 1,
|
||||
}));
|
||||
}}
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-border-bright px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500 focus-custom focus-visible:rounded-full!"
|
||||
>
|
||||
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="text-left transition group-hover:text-text-bright"
|
||||
>
|
||||
{example}
|
||||
</Paragraph>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { useState } from "react";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
import { TryableCodeBlock } from "./TRQLGuideContent";
|
||||
|
||||
// Example queries for the Examples tab
|
||||
export const exampleQueries: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
query: string;
|
||||
scope: QueryScope;
|
||||
table: string;
|
||||
}> = [
|
||||
{
|
||||
title: "Failed runs by task (past 7 days)",
|
||||
description: "Count of failed runs grouped by task identifier over the last 7 days.",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
count() AS failed_count
|
||||
FROM runs
|
||||
WHERE status = 'Failed'
|
||||
AND triggered_at > now() - INTERVAL 7 DAY
|
||||
GROUP BY task_identifier
|
||||
ORDER BY failed_count DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "Execution duration p50 by task (past 7d)",
|
||||
description: "Median (50th percentile) execution duration for each task.",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
quantile(0.5)(execution_duration) AS p50_duration_ms
|
||||
FROM runs
|
||||
WHERE triggered_at > now() - INTERVAL 7 DAY
|
||||
AND execution_duration IS NOT NULL
|
||||
GROUP BY task_identifier
|
||||
ORDER BY p50_duration_ms DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "Runs over time",
|
||||
description:
|
||||
"Count of runs bucketed over time. The bucket size adjusts automatically to the time range.",
|
||||
query: `SELECT
|
||||
timeBucket(),
|
||||
count() AS run_count
|
||||
FROM runs
|
||||
GROUP BY timeBucket
|
||||
ORDER BY timeBucket
|
||||
LIMIT 1000`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "Most expensive 100 runs (past 7d)",
|
||||
description: "Top 100 runs by cost over the last 7 days.",
|
||||
query: `SELECT
|
||||
run_id,
|
||||
task_identifier,
|
||||
status,
|
||||
total_cost,
|
||||
usage_duration,
|
||||
machine,
|
||||
triggered_at
|
||||
FROM runs
|
||||
WHERE triggered_at > now() - INTERVAL 7 DAY
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT 100`,
|
||||
scope: "environment",
|
||||
table: "runs",
|
||||
},
|
||||
{
|
||||
title: "CPU utilization over time",
|
||||
description: "Track process CPU utilization bucketed over time.",
|
||||
query: `SELECT
|
||||
timeBucket(),
|
||||
avg(metric_value) AS avg_cpu
|
||||
FROM metrics
|
||||
WHERE metric_name = 'process.cpu.utilization'
|
||||
GROUP BY timeBucket
|
||||
ORDER BY timeBucket
|
||||
LIMIT 1000`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
{
|
||||
title: "Memory usage by task (past 7d)",
|
||||
description: "Average memory usage per task identifier over the last 7 days.",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
avg(metric_value) AS avg_memory
|
||||
FROM metrics
|
||||
WHERE metric_name = 'system.memory.usage'
|
||||
AND bucket_start > now() - INTERVAL 7 DAY
|
||||
GROUP BY task_identifier
|
||||
ORDER BY avg_memory DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
{
|
||||
title: "Available metric names",
|
||||
description: "List all distinct metric names collected in your environment.",
|
||||
query: `SELECT
|
||||
metric_name,
|
||||
count() AS sample_count
|
||||
FROM metrics
|
||||
GROUP BY metric_name
|
||||
ORDER BY sample_count DESC
|
||||
LIMIT 100`,
|
||||
scope: "environment",
|
||||
table: "metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM cost by model (past 7d)",
|
||||
description:
|
||||
"Total cost, input tokens, and output tokens grouped by model over the last 7 days.",
|
||||
query: `SELECT
|
||||
response_model,
|
||||
SUM(total_cost) AS total_cost,
|
||||
SUM(input_tokens) AS input_tokens,
|
||||
SUM(output_tokens) AS output_tokens
|
||||
FROM llm_metrics
|
||||
WHERE start_time > now() - INTERVAL 7 DAY
|
||||
GROUP BY response_model
|
||||
ORDER BY total_cost DESC`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM cost over time",
|
||||
description: "Total LLM cost bucketed over time. The bucket size adjusts automatically.",
|
||||
query: `SELECT
|
||||
timeBucket(),
|
||||
SUM(total_cost) AS total_cost
|
||||
FROM llm_metrics
|
||||
GROUP BY timeBucket
|
||||
ORDER BY timeBucket
|
||||
LIMIT 1000`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "Most expensive runs by LLM cost (top 50)",
|
||||
description: "Top 50 runs by total LLM cost with token breakdown.",
|
||||
query: `SELECT
|
||||
run_id,
|
||||
task_identifier,
|
||||
SUM(total_cost) AS llm_cost,
|
||||
SUM(input_tokens) AS input_tokens,
|
||||
SUM(output_tokens) AS output_tokens
|
||||
FROM llm_metrics
|
||||
GROUP BY run_id, task_identifier
|
||||
ORDER BY llm_cost DESC
|
||||
LIMIT 50`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM calls by provider",
|
||||
description: "Count and cost of LLM calls grouped by AI provider.",
|
||||
query: `SELECT
|
||||
gen_ai_system,
|
||||
count() AS call_count,
|
||||
SUM(total_cost) AS total_cost
|
||||
FROM llm_metrics
|
||||
GROUP BY gen_ai_system
|
||||
ORDER BY total_cost DESC`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM cost by user",
|
||||
description:
|
||||
"Total LLM cost per user from run tags or AI SDK telemetry metadata. Uses metadata.userId which comes from experimental_telemetry metadata or run tags like user:123.",
|
||||
query: `SELECT
|
||||
metadata.userId AS user_id,
|
||||
SUM(total_cost) AS total_cost,
|
||||
SUM(total_tokens) AS total_tokens,
|
||||
count() AS call_count
|
||||
FROM llm_metrics
|
||||
WHERE metadata.userId != ''
|
||||
GROUP BY metadata.userId
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT 50`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
{
|
||||
title: "LLM cost by metadata key",
|
||||
description:
|
||||
"Browse all metadata keys and their LLM cost. Metadata comes from run tags (key:value) and AI SDK telemetry metadata.",
|
||||
query: `SELECT
|
||||
metadata,
|
||||
response_model,
|
||||
total_cost,
|
||||
total_tokens,
|
||||
run_id
|
||||
FROM llm_metrics
|
||||
ORDER BY start_time DESC
|
||||
LIMIT 20`,
|
||||
scope: "environment",
|
||||
table: "llm_metrics",
|
||||
},
|
||||
];
|
||||
|
||||
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
|
||||
|
||||
export function ExamplesContent({
|
||||
onTryExample,
|
||||
}: {
|
||||
onTryExample: (query: string, scope: QueryScope) => void;
|
||||
}) {
|
||||
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
|
||||
const filtered = exampleQueries.filter((e) => e.table === selectedTable);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="sticky top-0 z-10 bg-background-bright pb-3">
|
||||
<SegmentedControl
|
||||
name="examples-table-selector"
|
||||
value={selectedTable}
|
||||
options={tableOptions}
|
||||
variant="secondary/small"
|
||||
fullWidth
|
||||
onChange={setSelectedTable}
|
||||
/>
|
||||
</div>
|
||||
{filtered.map((example) => (
|
||||
<div key={example.title}>
|
||||
<Header3 className="mb-1 text-text-bright">{example.title}</Header3>
|
||||
<Paragraph variant="small" className="mb-2 text-text-dimmed">
|
||||
{example.description}
|
||||
</Paragraph>
|
||||
<TryableCodeBlock
|
||||
code={example.query}
|
||||
onTry={() => onTryExample(example.query, example.scope)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "~/components/primitives/ClientTabs";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
import { AITabContent } from "./AITabContent";
|
||||
import { ExamplesContent } from "./ExamplesContent";
|
||||
import { TableSchemaContent } from "./TableSchemaContent";
|
||||
import { TRQLGuideContent } from "./TRQLGuideContent";
|
||||
import type { AITimeFilter } from "./types";
|
||||
|
||||
export function QueryHelpSidebar({
|
||||
onTryExample,
|
||||
onQueryGenerated,
|
||||
onTimeFilterChange,
|
||||
getCurrentQuery,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
aiFixRequest,
|
||||
}: {
|
||||
onTryExample: (query: string, scope: QueryScope) => void;
|
||||
onQueryGenerated: (query: string) => void;
|
||||
onTimeFilterChange?: (filter: AITimeFilter) => void;
|
||||
getCurrentQuery: () => string;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
aiFixRequest: { prompt: string; key: number } | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<ClientTabs
|
||||
value={activeTab}
|
||||
onValueChange={onTabChange}
|
||||
className="flex min-h-0 flex-col overflow-hidden pt-1"
|
||||
>
|
||||
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<ClientTabsList variant="underline" className="mx-3 shrink-0">
|
||||
<ClientTabsTrigger
|
||||
value="ai"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<AISparkleIcon className="size-4" /> AI
|
||||
</div>
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="guide"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Writing TRQL
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="schema"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Table schema
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="examples"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Examples
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent
|
||||
value="ai"
|
||||
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">
|
||||
<AITabContent
|
||||
onQueryGenerated={onQueryGenerated}
|
||||
onTimeFilterChange={onTimeFilterChange}
|
||||
getCurrentQuery={getCurrentQuery}
|
||||
aiFixRequest={aiFixRequest}
|
||||
/>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="guide"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">
|
||||
<TRQLGuideContent onTryExample={onTryExample} />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="schema"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">
|
||||
<TableSchemaContent />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="examples"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<div className="min-w-64 p-3">
|
||||
<ExamplesContent onTryExample={onTryExample} />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
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 { cn } from "~/utils/cn";
|
||||
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"ORDER BY",
|
||||
"LIMIT",
|
||||
"GROUP BY",
|
||||
"HAVING",
|
||||
"JOIN",
|
||||
"LEFT JOIN",
|
||||
"RIGHT JOIN",
|
||||
"INNER JOIN",
|
||||
"OUTER JOIN",
|
||||
"AND",
|
||||
"OR",
|
||||
"AS",
|
||||
"ON",
|
||||
"IN",
|
||||
"NOT",
|
||||
"NULL",
|
||||
"DESC",
|
||||
"ASC",
|
||||
"DISTINCT",
|
||||
"COUNT",
|
||||
"SUM",
|
||||
"AVG",
|
||||
"MIN",
|
||||
"MAX",
|
||||
];
|
||||
|
||||
function highlightSQL(query: string): React.ReactNode[] {
|
||||
// Normalize: collapse multiple spaces/tabs to single space, but preserve newlines
|
||||
// Then trim each line and limit total length
|
||||
const normalized = query
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n")
|
||||
.slice(0, 500);
|
||||
|
||||
// Create a regex pattern that matches keywords as whole words (case insensitive)
|
||||
const keywordPattern = new RegExp(
|
||||
`\\b(${SQL_KEYWORDS.map((k) => k.replace(/\s+/g, "\\s+")).join("|")})\\b`,
|
||||
"gi"
|
||||
);
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = keywordPattern.exec(normalized)) !== null) {
|
||||
// Add text before the match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(normalized.slice(lastIndex, match.index));
|
||||
}
|
||||
// Add the highlighted keyword
|
||||
parts.push(
|
||||
<span key={match.index} className="text-[#c678dd]">
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
lastIndex = keywordPattern.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < normalized.length) {
|
||||
parts.push(normalized.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function QueryHistoryPopover({
|
||||
history,
|
||||
onQuerySelected,
|
||||
}: {
|
||||
history: QueryHistoryItem[];
|
||||
onQuerySelected: (item: QueryHistoryItem) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ClockRotateLeftIcon}
|
||||
leadingIconClassName="-mr-1.5"
|
||||
disabled={history.length === 0}
|
||||
tooltip="Query history"
|
||||
shortcut={{ key: "h" }}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPrimitive.Content
|
||||
className={cn(
|
||||
"z-50 w-[400px] min-w-0 overflow-hidden rounded border border-grid-bright bg-background-bright p-0 shadow-md outline-hidden animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
)}
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
|
||||
>
|
||||
<div className="max-h-160 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="p-1">
|
||||
{history.map((item) => {
|
||||
// Format time filter display
|
||||
const { valueLabel } = timeFilterRenderValues({
|
||||
period: item.filterPeriod ?? undefined,
|
||||
from: item.filterFrom ?? undefined,
|
||||
to: item.filterTo ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onQuerySelected(item);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="flex w-full flex-col gap-1 rounded-sm px-2 py-2 outline-hidden transition-colors focus-custom hover:bg-background-hover"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start">
|
||||
{item.title ? (
|
||||
<p className="mb-1 truncate text-left text-sm font-medium text-text-bright">
|
||||
{item.title}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-1 truncate text-left font-mono text-xs text-text-bright">
|
||||
{item.query.split("\n")[0].slice(0, 60)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
|
||||
<span className="capitalize">{item.scope}</span>
|
||||
{valueLabel && <span>· {valueLabel}</span>}
|
||||
{item.userName && <span>· {item.userName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full border-l-2 border-border-bright pl-2.5">
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPrimitive.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
+1208
File diff suppressed because it is too large
Load Diff
+78
@@ -0,0 +1,78 @@
|
||||
import type { ColumnSchema } from "@internal/tsql";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
|
||||
function ColumnHelpItem({ col }: { col: ColumnSchema }) {
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyableText value={col.name} className="text-sm text-indigo-400" />
|
||||
<Badge className="font-mono text-xxs">{col.type}</Badge>
|
||||
</div>
|
||||
{col.description && (
|
||||
<Paragraph variant="extra-small" className="mt-1 text-text-dimmed">
|
||||
{col.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
{col.example && (
|
||||
<div className="mt-1 flex items-baseline gap-0.5">
|
||||
<span className="text-xs text-text-dimmed">Example:</span>
|
||||
<CopyableText
|
||||
value={col.example}
|
||||
className="rounded-sm bg-background-hover px-1.5 py-0.5 font-mono text-xxs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{col.allowedValues && col.allowedValues.length > 0 && (
|
||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||
<span className="text-xs text-text-dimmed">Available options:</span>
|
||||
{col.allowedValues.map((value) => (
|
||||
<CopyableText
|
||||
key={value}
|
||||
value={col.valueMap?.[value] ?? value}
|
||||
className="rounded-sm bg-background-hover px-1.5 py-0.5 font-mono text-xxs"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
|
||||
|
||||
export function TableSchemaContent() {
|
||||
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
|
||||
const table = querySchemas.find((s) => s.name === selectedTable) ?? querySchemas[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-0 z-10 bg-background-bright pb-3">
|
||||
<SegmentedControl
|
||||
name="table-schema-selector"
|
||||
value={selectedTable}
|
||||
options={tableOptions}
|
||||
variant="secondary/small"
|
||||
fullWidth
|
||||
onChange={setSelectedTable}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
{table.description && (
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{table.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 divide-y divide-grid-dimmed">
|
||||
{Object.values(table.columns).map((col) => (
|
||||
<ColumnHelpItem key={col.name} col={col} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
redirect,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { QueryEditor } from "~/components/query/QueryEditor";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
|
||||
import { executeQuery, getDefaultPeriod } from "~/services/queryService.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema, queryPath } from "~/utils/pathBuilder";
|
||||
import { canAccessQuery } from "~/v3/canAccessQuery.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
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 {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await canAccessQuery({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new QueryPresenter();
|
||||
const { defaultQuery, history } = await presenter.call({
|
||||
organizationId: project.organizationId,
|
||||
});
|
||||
|
||||
// Admins and impersonating users can use EXPLAIN
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
|
||||
return typedjson({
|
||||
defaultQuery,
|
||||
defaultPeriod: await getDefaultPeriod(project.organizationId),
|
||||
history,
|
||||
isAdmin,
|
||||
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
|
||||
});
|
||||
};
|
||||
|
||||
const ActionSchema = z.object({
|
||||
query: z.string().min(1, "Query is required"),
|
||||
scope: z.enum(["environment", "project", "organization"]),
|
||||
explain: z.enum(["true", "false"]).nullable().optional(),
|
||||
period: z.string().nullable().optional(),
|
||||
from: z.string().nullable().optional(),
|
||||
to: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await canAccessQuery({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
});
|
||||
|
||||
if (!canAccess) {
|
||||
return typedjson(
|
||||
{
|
||||
error: "Unauthorized",
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
return typedjson(
|
||||
{
|
||||
error: "Project not found",
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
return typedjson(
|
||||
{
|
||||
error: "Environment not found",
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const parsed = ActionSchema.safeParse({
|
||||
query: formData.get("query"),
|
||||
scope: formData.get("scope"),
|
||||
explain: formData.get("explain"),
|
||||
period: formData.get("period"),
|
||||
from: formData.get("from"),
|
||||
to: formData.get("to"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return typedjson(
|
||||
{
|
||||
error: parsed.error.errors.map((e) => e.message).join(", "),
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { query, scope, explain: explainParam, period, from, to } = parsed.data;
|
||||
// Only allow explain for admins/impersonating users
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
const explain = explainParam === "true" && isAdmin;
|
||||
|
||||
try {
|
||||
const queryResult = await executeQuery({
|
||||
name: "query-page",
|
||||
query,
|
||||
scope,
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
explain,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
history: {
|
||||
source: "DASHBOARD",
|
||||
userId: user.id,
|
||||
skip: user.isImpersonating,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queryResult.success) {
|
||||
return typedjson(
|
||||
{
|
||||
error: queryResult.error.message,
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
error: 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";
|
||||
return typedjson(
|
||||
{
|
||||
error: errorMessage,
|
||||
rows: null,
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { defaultPeriod, defaultQuery, history, isAdmin, maxRows } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
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 = history.length > 0 ? history[0].scope : "environment";
|
||||
const initialTimeFilter =
|
||||
history.length > 0
|
||||
? {
|
||||
period: history[0].filterPeriod ?? undefined,
|
||||
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
|
||||
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Build the query action URL for this page
|
||||
const queryActionUrl = queryPath(
|
||||
{ slug: organization.slug },
|
||||
{ slug: project.slug },
|
||||
{ slug: environment.slug }
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryEditor
|
||||
defaultQuery={initialQuery}
|
||||
defaultScope={initialScope}
|
||||
defaultPeriod={defaultPeriod}
|
||||
defaultTimeFilter={initialTimeFilter}
|
||||
history={history}
|
||||
isAdmin={isAdmin}
|
||||
maxRows={maxRows}
|
||||
queryActionUrl={queryActionUrl}
|
||||
mode={{ type: "standalone" }}
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Time filter configuration that can be set by the AI.
|
||||
* Used across both server and client code for AI query generation.
|
||||
*/
|
||||
export type AITimeFilter = {
|
||||
period?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user